@smartsoft001-mobilems/objects-shell-angular 2.125.0 → 2.127.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,11 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, inject, contentChild, ChangeDetectionStrategy, Component, NgModule, Directive, signal, effect, PLATFORM_ID, viewChild, computed, untracked } from '@angular/core';
2
+ import { Injectable, inject, contentChild, ChangeDetectionStrategy, Component, NgModule, PLATFORM_ID, isDevMode, Directive, signal, effect, viewChild, computed, untracked } from '@angular/core';
3
3
  import { FILE_SERVICE_CONFIG, PaginationMode, ListMode } from '@smartsoft001/angular';
4
4
  import { CrudFacade, initialState as initialState$1, getReducer as getReducer$1, CrudService, CrudConfig, CrudFullConfig, CrudCoreModule, CrudModule, CrudListPageBaseComponent, CrudItemPageBaseComponent } from '@smartsoft001/crud-shell-angular';
5
- import { CrudBaseService, FiltersBaseComponent, provideInheritedSharedConfig, SharedModule, SeoService, TranslationService, MuseumSeoService, WcagChangesService, createSafeTimeouts, MasonryGridComponent, createCatalog, createCatalogLoadMore } from '@smartsoft001-mobilems/angular';
6
- import { NgTemplateOutlet, CommonModule, isPlatformBrowser } from '@angular/common';
5
+ import { CrudBaseService, FiltersBaseComponent, DICTIONARY_PAGE_SIZE, FileUrlService, provideInheritedSharedConfig, SharedModule, SeoService, TranslationService, MuseumSeoService, WcagChangesService, createSafeTimeouts, MasonryGridComponent, createCatalog, createCatalogLoadMore, toPlainText, namedEntryParts } from '@smartsoft001-mobilems/angular';
6
+ import { NgTemplateOutlet, CommonModule, isPlatformServer, isPlatformBrowser, DOCUMENT } from '@angular/common';
7
+ import { firstValueFrom, retry, timer, throwError, map, tap, catchError, of, expand, reduce, switchMap, from, mergeMap, toArray, filter, takeUntil } from 'rxjs';
7
8
  import { createFeatureSelector } from '@ngrx/store';
8
- import { toSignal } from '@angular/core/rxjs-interop';
9
+ import { toSignal, toObservable, rxResource } from '@angular/core/rxjs-interop';
9
10
  import { ActivatedRoute } from '@angular/router';
10
11
  import { TranslateService } from '@ngx-translate/core';
11
12
  import { capitalize } from '@smartsoft001/utils';
@@ -100,8 +101,262 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
100
101
  }]
101
102
  }] });
102
103
 
104
+ /** Pause before the only retry of `related` after a network error (ms). */
105
+ const RELATED_RETRY_DELAY = 500;
106
+ /** Page size of the public collections list (the API accepts up to 500). */
107
+ const PUBLIC_COLLECTIONS_PAGE_SIZE = 100;
108
+ /** Page size of `search/Object` when checking an object's membership. */
109
+ const COLLECTION_OBJECTS_PAGE_SIZE = 500;
110
+ /** How many collections are checked at once (API 429 limit). */
111
+ const COLLECTION_LOOKUP_CONCURRENCY = 4;
112
+ /**
113
+ * The API answers 404 `NO_RESULTS_FOR_GIVEN_QUERY` instead of an empty list —
114
+ * only that 404 means "no results"; any other 404 (wrong address, proxy) is an error.
115
+ */
116
+ const isNoResults = (error) => error?.status === 404 &&
117
+ error.error?.message ===
118
+ 'NO_RESULTS_FOR_GIVEN_QUERY';
119
+ /**
120
+ * `ObjectsService` plus the reads of the object details page and the filter
121
+ * dictionaries. A subclass, so `ObjectsService` (the `CrudService` of the
122
+ * objects module) stays unchanged. Request URLs, parameter order included,
123
+ * are transfer cache keys: keep them as they are.
124
+ */
125
+ class ObjectsDataService extends ObjectsService {
126
+ constructor() {
127
+ super(...arguments);
128
+ this.objectsDataIsServer = isPlatformServer(inject(PLATFORM_ID));
129
+ }
130
+ /**
131
+ * Counts a visit of the object card: `POST api/object/:id/visit`. Browser
132
+ * only — the caller runs it under `isPlatformBrowser` and catches its errors.
133
+ */
134
+ logVisit(id) {
135
+ const cardUrl = this.config.apiUrl + 'api/object/' + id + '/visit';
136
+ return firstValueFrom(this.http.post(cardUrl, {}));
137
+ }
138
+ /**
139
+ * "You may also be interested in" — `GET object/:id/related` (objects,
140
+ * collections, articles). A network error (status 0) is retried once; any
141
+ * other error, or a second failed request, goes on to the `rxResource`
142
+ * (error state — the section stays hidden, `reload()` retries). In dev mode
143
+ * the error is logged, except a 404 on the server: there `related` starts
144
+ * next to the card (MOB-1850), so a missing object gives two 404s and the
145
+ * card view shows the error. In the browser `related` follows the card — a
146
+ * 404 is unexpected there and stays in the console.
147
+ */
148
+ getRelated(id) {
149
+ return this.http
150
+ .get(`${this.config.apiUrl}object/${id}/related`)
151
+ .pipe(retry({
152
+ count: 1,
153
+ delay: (error) => error?.status === 0
154
+ ? timer(RELATED_RETRY_DELAY)
155
+ : throwError(() => error),
156
+ }), map((response) => ({
157
+ objects: response?.data?.objects ?? [],
158
+ collections: response?.data?.collections ?? [],
159
+ articles: response?.data?.articles ?? [],
160
+ })), tap({
161
+ error: (error) => {
162
+ if (isDevMode() &&
163
+ !(this.objectsDataIsServer && error?.status === 404)) {
164
+ console.error(`object/${id}/related`, error);
165
+ }
166
+ },
167
+ }));
168
+ }
169
+ /**
170
+ * One page of the public collections list, newest publication first
171
+ * (`sort=publicationDate-desc`), with the phrase in `filter[phrase]` (the
172
+ * "Field description" document: "Search in collections: filter:phrase").
173
+ * 404 `NO_RESULTS_FOR_GIVEN_QUERY` is an empty page, any other error propagates.
174
+ */
175
+ loadPublicCollectionsPage(page = 1, query) {
176
+ const phrase = query?.trim();
177
+ const url = `${this.config.apiUrl}public-collections/page/${page}` +
178
+ `?maxPerPage=${PUBLIC_COLLECTIONS_PAGE_SIZE}&sort=publicationDate-desc` +
179
+ (phrase ? `&filter[phrase]=${encodeURIComponent(phrase)}` : '');
180
+ return this.http.get(url).pipe(map((response) => ({
181
+ items: (response?.data?.items ?? [])
182
+ .filter((item) => item && item.id !== null && item.id !== undefined)
183
+ .map((item) => ({
184
+ id: item.id,
185
+ title: item.title?.trim() ?? '',
186
+ publicationDate: item.publicationDate ?? null,
187
+ itemCount: typeof item.itemCount === 'number' ? item.itemCount : null,
188
+ })),
189
+ totalPagesCount: Math.max(1, response?.data?.paginatorDetails?.totalPagesCount ?? 1),
190
+ })), catchError((error) => isNoResults(error)
191
+ ? of({ items: [], totalPagesCount: 1 })
192
+ : throwError(() => error)));
193
+ }
194
+ /**
195
+ * Public collections as the dictionary of the "Thematic collection" field in
196
+ * the advanced filters (MOB-1844): `filter[entryCollections]` in
197
+ * `search/Object` takes ids from `public-collections`, not from the
198
+ * `ECollection` dictionary.
199
+ */
200
+ loadCollectionsDictionary(page = 1, query) {
201
+ return this.loadPublicCollectionsPage(page, query).pipe(map((result) => ({
202
+ items: result.items.map((item) => ({
203
+ id: Number(item.id),
204
+ name: item.title,
205
+ })),
206
+ totalPagesCount: result.totalPagesCount,
207
+ })));
208
+ }
209
+ /**
210
+ * Dictionary of the "Archaeological filters" fields (MOB-1844). In Muza it is
211
+ * `ArchaeologicalData` segregated into `ArcheoTown|ArcheoPeriod|ArcheoCulture`
212
+ * (Kanbanize 50040), but MobileMS exposed each segregation as its own
213
+ * dictionary type: `dictionary/ArcheoTown/search/page/{n}` (develop
214
+ * 2026-10-21 — the type is recognised, `ArchaeologicalData` still 409). Same
215
+ * path and shape as the framework's `DataService.loadFilterDictionary`. Until
216
+ * production has these types the API answers 409 and the field shows a load error.
217
+ */
218
+ loadArchaeologicalDictionary(segregation, page = 1, query) {
219
+ const phrase = query?.trim();
220
+ // No phrase, no `q` — the API treats an empty `q=` as a query with no results.
221
+ const url = `${this.config.apiUrl}api/dictionary/${segregation}/search/page/${page}` +
222
+ `?maxPerPage=${DICTIONARY_PAGE_SIZE}&_type=query` +
223
+ (phrase ? `&q=${encodeURIComponent(phrase)}` : '');
224
+ return this.http.get(url).pipe(map((response) => ({
225
+ items: response?.data?.items ?? [],
226
+ totalPagesCount: response?.data?.paginatorDetails?.totalPagesCount ?? 1,
227
+ })));
228
+ }
229
+ /** All public collections (consecutive pages of the list). */
230
+ loadAllPublicCollections() {
231
+ return this.loadPublicCollectionsPage(1).pipe(expand((result, index) => index + 2 <= result.totalPagesCount
232
+ ? this.loadPublicCollectionsPage(index + 2)
233
+ : []), reduce((all, result) => [...all, ...result.items], []));
234
+ }
235
+ /**
236
+ * "Related collections" of the object card per the "Field description"
237
+ * document: `search/Object` with `filter[entryCollections]`, sorted by
238
+ * publication date. The API gives no reverse direction (`portalCollections`
239
+ * and `related.collections` are empty also for objects in a collection), so
240
+ * for each public collection we check whether its objects contain this
241
+ * object — a few collections at a time, in list order. A collection that
242
+ * could not be checked is skipped; a failed list means no section (an empty
243
+ * list), not a page error.
244
+ */
245
+ loadRelatedCollections(objectId) {
246
+ return this.loadAllPublicCollections().pipe(switchMap((collections) => from(collections).pipe(mergeMap((collection) => this.objectsDataCollectionContains(collection, objectId).pipe(map((contains) => (contains ? collection : null))), COLLECTION_LOOKUP_CONCURRENCY), toArray(), map((matches) => collections.filter((collection) => matches.includes(collection))))), catchError(() => of([])));
247
+ }
248
+ /** Whether `search/Object` with the collection's `filter[entryCollections]` contains the object; an error → unknown → `false`. */
249
+ objectsDataCollectionContains(collection, objectId) {
250
+ const wanted = String(objectId);
251
+ const pageSize = Math.min(COLLECTION_OBJECTS_PAGE_SIZE, Math.max(1, collection.itemCount ?? COLLECTION_OBJECTS_PAGE_SIZE));
252
+ const load = (page) => this.http
253
+ .get(`${this.config.apiUrl}search/Object/page/${page}` +
254
+ `?maxPerPage=${pageSize}` +
255
+ `&filter[entryCollections][]=${encodeURIComponent(String(collection.id))}`)
256
+ .pipe(map((response) => ({
257
+ found: (response?.data?.items ?? []).some((item) => String(item?.id) === wanted),
258
+ totalPagesCount: Math.max(1, response?.data?.paginatorDetails?.totalPagesCount ?? 1),
259
+ })),
260
+ // A collection without objects → 404 `NO_RESULTS_FOR_GIVEN_QUERY`.
261
+ catchError((error) => isNoResults(error)
262
+ ? of({ found: false, totalPagesCount: 1 })
263
+ : throwError(() => error)));
264
+ return load(1).pipe(expand((result, index) => !result.found && index + 2 <= result.totalPagesCount
265
+ ? load(index + 2)
266
+ : []), reduce((found, result) => found || result.found, false), catchError(() => of(false)));
267
+ }
268
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsDataService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
269
+ /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsDataService }); }
270
+ }
271
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsDataService, decorators: [{
272
+ type: Injectable
273
+ }] });
274
+
275
+ /**
276
+ * Shared listing of an object's media (the main image + `additionalImages`
277
+ * filtered by `formFeature`) — used by the details page image viewer
278
+ * (MOB-1801), the hero gallery, the thumbnails and the HD images section.
279
+ */
280
+ class ObjectMediaService {
281
+ constructor() {
282
+ this.objectMediaFileUrl = inject(FileUrlService);
283
+ }
284
+ /** Gallery items (full size, for fullscreen). */
285
+ galleryItems(obj, types, includeMainImage = true) {
286
+ if (!obj)
287
+ return [];
288
+ const out = [];
289
+ if (obj.image && includeMainImage) {
290
+ const url = this.objectMediaFileUrl.get(obj.image);
291
+ if (!url?.includes('assets/no-image.svg')) {
292
+ out.push({ title: obj.title, image: obj.image });
293
+ }
294
+ }
295
+ for (const img of this.objectMediaAdditional(obj, types)) {
296
+ const url = this.objectMediaFileUrl.get(img);
297
+ if (!url?.includes('assets/no-image.svg')) {
298
+ out.push({ title: obj.title, image: img });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ /** Thumbnail URLs (`cache` mode + a forced `.jpg` for the CDN). */
304
+ thumbnailUrls(obj, types, includeMainImage = true) {
305
+ if (!obj)
306
+ return [];
307
+ const urls = [];
308
+ if (obj.image && includeMainImage) {
309
+ const url = this.objectMediaJpg(this.objectMediaFileUrl.get(obj.image, 'cache'));
310
+ if (url && !url.includes('assets/no-image.svg')) {
311
+ urls.push(url);
312
+ }
313
+ }
314
+ for (const img of this.objectMediaAdditional(obj, types)) {
315
+ const url = this.objectMediaJpg(this.objectMediaFileUrl.get(img, 'cache'));
316
+ if (url && !url.includes('assets/no-image.svg')) {
317
+ urls.push(url);
318
+ }
319
+ }
320
+ return urls;
321
+ }
322
+ /**
323
+ * Whether the object has files of the types (video, 3D model) — by
324
+ * `filePath`, because `FileUrlService` returns a placeholder for
325
+ * non-image extensions.
326
+ */
327
+ hasFiles(obj, types) {
328
+ if (!obj)
329
+ return false;
330
+ return this.objectMediaAdditional(obj, types).some((file) => !!file?.filePath);
331
+ }
332
+ objectMediaAdditional(obj, types) {
333
+ const additional = obj
334
+ .additionalImages;
335
+ if (!Array.isArray(additional))
336
+ return [];
337
+ return additional
338
+ .filter((img) => types.includes(img?.formFeature))
339
+ .sort((a, b) => (a?.position ?? 0) - (b?.position ?? 0));
340
+ }
341
+ /** Replaces the extension with `.jpg` — CDN cache compatibility. */
342
+ objectMediaJpg(path) {
343
+ const lastDotIndex = path.lastIndexOf('.');
344
+ if (lastDotIndex > -1) {
345
+ return path.substring(0, lastDotIndex) + '.jpg';
346
+ }
347
+ return path + '.jpg';
348
+ }
349
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
350
+ /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaService, providedIn: 'root' }); }
351
+ }
352
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaService, decorators: [{
353
+ type: Injectable,
354
+ args: [{ providedIn: 'root' }]
355
+ }] });
356
+
103
357
  const SERVICES = [
104
358
  ObjectsService,
359
+ ObjectsDataService,
105
360
  {
106
361
  provide: CrudService,
107
362
  useClass: ObjectsService,
@@ -201,6 +456,7 @@ class ObjectsItemComponent extends CrudItemPageBaseComponent {
201
456
  this.facade = inject(ObjectsFacade);
202
457
  this.loading = this.facade.loading;
203
458
  this.item = this.facade.selected;
459
+ /** @deprecated No museum uses it; download the card through the app's own download service. */
204
460
  this.isPdfDownloading = signal(false, /* @ts-ignore */
205
461
  ...(ngDevMode ? [{ debugName: "isPdfDownloading" }] : /* istanbul ignore next */ []));
206
462
  effect(() => {
@@ -211,6 +467,7 @@ class ObjectsItemComponent extends CrudItemPageBaseComponent {
211
467
  }
212
468
  });
213
469
  }
470
+ /** @deprecated No museum uses it; download the card through the app's own download service. */
214
471
  async onDownload() {
215
472
  this.isPdfDownloading.set(true);
216
473
  try {
@@ -564,9 +821,288 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
564
821
  type: Directive
565
822
  }], ctorParameters: () => [{ type: undefined }], propDecorators: { objectsCatalogMasonry: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MasonryGridComponent), { isSignal: true }] }], objectsCatalogListContainer: [{ type: i0.ViewChild, args: ['listContainer', { isSignal: true }] }] } });
566
823
 
824
+ const OBJECT_DETAILS_SITE_NAME_KEY = 'MODEL.mainTitle';
825
+ /** Muzeum Podlasia's tag strip — an unclosed trailing tag (`<br`) goes too. */
826
+ function objectDetailsStripHtml(html) {
827
+ if (!html)
828
+ return '';
829
+ return html.replace(/<\/?[^>]+(>|$)/g, '');
830
+ }
831
+ /**
832
+ * Base of an object details page (`/obiekty/:id`, MOB-1801), from Muzeum
833
+ * Podlasia's item page: the SEO (also in SSR), the item effect with the
834
+ * `onObjectShown` hook (default: scroll to the top in the browser), the
835
+ * browser-only visit counter, `hasError`, `retryLoad`, `altTitle` and two
836
+ * opt-in resources — `related` and `relatedCollections`. The page keeps its
837
+ * template.
838
+ *
839
+ * ```ts
840
+ * // Muzeum Podlasia
841
+ * export class ItemComponent extends ObjectDetailsBaseComponent {
842
+ * constructor() {
843
+ * super({ related: true, relatedCollections: true });
844
+ * }
845
+ * }
846
+ * ```
847
+ */
848
+ class ObjectDetailsBaseComponent extends ObjectsItemComponent {
849
+ constructor(options = {}) {
850
+ super();
851
+ this.objectDetailsService = inject(ObjectsDataService);
852
+ this.objectDetailsSeoService = inject(MuseumSeoService);
853
+ this.objectDetailsFileUrl = inject(FileUrlService);
854
+ this.objectDetailsDocument = inject(DOCUMENT);
855
+ this.objectDetailsBrowser = isPlatformBrowser(inject(PLATFORM_ID));
856
+ this.objectDetailsServer = !this.objectDetailsBrowser;
857
+ /** Object id from the route (`/obiekty/:id` and `/obiekty/:id/:slug`). */
858
+ this.objectDetailsRouteId = computed(() => this.params()?.['id'], /* @ts-ignore */
859
+ ...(ngDevMode ? [{ debugName: "objectDetailsRouteId" }] : /* istanbul ignore next */ []));
860
+ /**
861
+ * Id for „related” (MOB-1850). On the server from the route:
862
+ * `GET object/:id/related` starts together with `GET object/:id`, so SSR
863
+ * waits for the longer of the two requests, not their sum (card 5233:
864
+ * ~400 → ~290 ms locally). In the browser unchanged — after the card (no
865
+ * extra 404 for a missing object, „Try again” works as before). A
866
+ * non-numeric id waits for the card. `computed`, because `rxResource`
867
+ * does not compare the `params` result — every change of a read signal
868
+ * would repeat the request.
869
+ */
870
+ this.objectDetailsRelatedId = computed(() => {
871
+ const id = this.objectDetailsRouteId();
872
+ return this.objectDetailsServer && id && /^\d+$/.test(id)
873
+ ? id
874
+ : this.item()?.id;
875
+ }, /* @ts-ignore */
876
+ ...(ngDevMode ? [{ debugName: "objectDetailsRelatedId" }] : /* istanbul ignore next */ []));
877
+ /** The card failed — „related” started on the server has nothing to wait for. */
878
+ this.objectDetailsCardFailed$ = toObservable(this.facade.error).pipe(filter(Boolean));
879
+ this.hasError = this.facade.error;
880
+ /** The object title without markup — image `alt`s. `''` without an object. */
881
+ this.altTitle = computed(() => objectDetailsStripHtml(this.item()?.title ?? ''), /* @ts-ignore */
882
+ ...(ngDevMode ? [{ debugName: "altTitle" }] : /* istanbul ignore next */ []));
883
+ this.objectDetailsOptions = options;
884
+ this.related = rxResource({
885
+ params: () => this.objectDetailsOptions.related
886
+ ? this.objectDetailsRelatedId()
887
+ : undefined,
888
+ stream: ({ params }) => this.objectDetailsService
889
+ .getRelated(params)
890
+ .pipe(takeUntil(this.objectDetailsCardFailed$)),
891
+ });
892
+ this.relatedCollections = rxResource({
893
+ params: () => this.objectDetailsOptions.relatedCollections &&
894
+ this.objectDetailsBrowser
895
+ ? this.item()?.id
896
+ : undefined,
897
+ stream: ({ params }) => this.objectDetailsService.loadRelatedCollections(params),
898
+ defaultValue: [],
899
+ });
900
+ this.objectDetailsSeoService.applyForSsr(() => this.objectDetailsFetchSsrSeo());
901
+ effect(() => {
902
+ const item = this.item();
903
+ if (item) {
904
+ untracked(() => {
905
+ this.objectDetailsSeoService.apply(this.objectDetailsSeo(item));
906
+ this.onObjectShown(item);
907
+ });
908
+ }
909
+ });
910
+ }
911
+ /**
912
+ * Visit counter — browser only (SSR would count the visit a second time),
913
+ * and its error (e.g. a 429 of the API limit) must neither reach the
914
+ * console as an unhandled `HttpErrorResponse` nor break the view. Once per
915
+ * page instance: in-page navigation to another object sends no new visit.
916
+ */
917
+ ngOnInit() {
918
+ if (!this.objectDetailsBrowser)
919
+ return;
920
+ this.objectDetailsService
921
+ .logVisit(Number(this.route.snapshot.paramMap.get('id')))
922
+ .catch(() => undefined);
923
+ }
924
+ retryLoad() {
925
+ const id = this.route.snapshot.paramMap.get('id');
926
+ if (id)
927
+ this.facade.select(id);
928
+ }
929
+ /** SEO of the object: `<title> - <site>`, description, image and its alt. */
930
+ objectDetailsSeo(item) {
931
+ const strippedTitle = objectDetailsStripHtml(item.title);
932
+ const siteName = this.translationService.get(this.objectDetailsOptions.siteNameKey ?? OBJECT_DETAILS_SITE_NAME_KEY);
933
+ return {
934
+ title: `${strippedTitle} - ${siteName}`,
935
+ description: item.description
936
+ ? objectDetailsStripHtml(item.description)
937
+ : undefined,
938
+ image: item.image ? this.objectDetailsFileUrl.get(item.image) : undefined,
939
+ imageAlt: strippedTitle,
940
+ type: 'article',
941
+ };
942
+ }
943
+ /**
944
+ * Called (untracked) after the SEO for every object the facade selects.
945
+ * Default: scroll to the top in the browser. Override to keep the scroll
946
+ * on a re-read of the same card (MOB-1961) or to reset a gallery.
947
+ */
948
+ onObjectShown(item) {
949
+ void item;
950
+ if (this.objectDetailsBrowser) {
951
+ this.objectDetailsDocument.defaultView?.scrollTo({
952
+ top: 0,
953
+ behavior: 'instant',
954
+ });
955
+ }
956
+ }
957
+ async objectDetailsFetchSsrSeo() {
958
+ const id = this.route.snapshot.paramMap.get('id');
959
+ if (!id)
960
+ return null;
961
+ try {
962
+ const item = await this.objectDetailsService.getById(id);
963
+ return item ? this.objectDetailsSeo(item) : null;
964
+ }
965
+ catch {
966
+ return null;
967
+ }
968
+ }
969
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectDetailsBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
970
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectDetailsBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
971
+ }
972
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectDetailsBaseComponent, decorators: [{
973
+ type: Directive
974
+ }], ctorParameters: () => [{ type: undefined }] });
975
+
976
+ const objectDetailHasText = (value) => !!value?.trim();
977
+ const objectDetailBare = (entries) => entries.map((entry) => ({ ...entry, role: null, comment: null }));
978
+ const objectDetailTextValue = (value) => objectDetailHasText(value)
979
+ ? [{ name: value.trim(), role: null, comment: null }]
980
+ : [];
981
+ /**
982
+ * A link title without HTML (the articles API sends `<p>…</p>` with entities,
983
+ * MOB-1845) — the framework's `toPlainText` strips tags also after decoding
984
+ * entities (`&lt;p&gt;`), like `PlainTextPipe` on the article cards.
985
+ */
986
+ const objectDetailPlainTitle = (title) => toPlainText(title);
987
+ /** The object title — the document says `name`, the details API returns `title`. */
988
+ function objectTitle(obj) {
989
+ return (obj.title ?? obj.name ?? '').trim();
990
+ }
991
+ /** Header tags: `seriesTitles` (the `Cycle` dictionary) → a list with the `cycles` filter. */
992
+ function tagValues(obj) {
993
+ return objectDetailBare(namedEntryParts(obj.seriesTitles));
994
+ }
995
+ /** Inventory number: `noEvidence`. */
996
+ function inventoryValues(obj) {
997
+ return objectDetailTextValue(obj.noEvidence);
998
+ }
999
+ /**
1000
+ * Bibliography as in OSSO (`museum-objects` item/main-tab): every
1001
+ * `bibliographyMNK[]` entry is a separate item, and its `text` (HTML from the
1002
+ * API) goes whole — OSSO does not split the text on full stops. An entry
1003
+ * without text is dropped; `permalink` makes the item a link.
1004
+ */
1005
+ function bibliography(obj) {
1006
+ const source = obj.bibliographyMNK;
1007
+ const entries = typeof source === 'string' ? [source] : (source ?? []);
1008
+ // A loop instead of `flatMap` — the library's TS `lib` predates ES2019.
1009
+ const out = [];
1010
+ for (const entry of entries) {
1011
+ const text = typeof entry === 'string' ? entry : (entry?.text ?? '');
1012
+ if (!objectDetailHasText(text))
1013
+ continue;
1014
+ const permalink = typeof entry === 'string' || !objectDetailHasText(entry?.permalink)
1015
+ ? null
1016
+ : entry.permalink.trim();
1017
+ out.push({ text: text.trim(), permalink });
1018
+ }
1019
+ return out;
1020
+ }
1021
+ /**
1022
+ * Description (HTML from the API): `publicDescriptions[].text` (the
1023
+ * popular-science description), and without it `description` (the physical
1024
+ * description) — the „Opis pól” rule of MOB-1563 („popular-science
1025
+ * description, and if there is none — the physical one”); object 5233 has
1026
+ * only `description` (MOB-1801). The description does not depend on media.
1027
+ */
1028
+ function descriptionHtml(obj) {
1029
+ // An empty editor paragraph (`<p></p>`, `<p>&nbsp;</p>`) is not a description.
1030
+ const visible = (text) => toPlainText(text).length > 0;
1031
+ const popular = (obj.publicDescriptions ?? [])
1032
+ .map((entry) => entry?.text ?? '')
1033
+ .filter(visible);
1034
+ if (popular.length)
1035
+ return popular;
1036
+ const physical = obj.description ?? '';
1037
+ return visible(physical) ? [physical.trim()] : [];
1038
+ }
1039
+ /**
1040
+ * „Powiązane artykuły” — the card's `articles` field; the title without HTML
1041
+ * (MOB-1845). `routePrefix` is the articles list route of the museum.
1042
+ */
1043
+ function relatedArticles(obj, routePrefix = '/artykuly') {
1044
+ return (obj.articles ?? [])
1045
+ .map((article) => ({
1046
+ id: article.id,
1047
+ title: objectDetailPlainTitle(article?.title),
1048
+ route: `${routePrefix}/${article.id}`,
1049
+ }))
1050
+ .filter((article) => article.title.length > 0);
1051
+ }
1052
+
1053
+ /**
1054
+ * „Zapytaj o obiekt” keys (MOB-1869) — the mail address and text live in the
1055
+ * CMS translations, so a museum changes them without a deployment.
1056
+ */
1057
+ const ASK_ABOUT_OBJECT_KEYS = {
1058
+ email: 'DETAILS_PAGE.detail.askEmail',
1059
+ subject: 'DETAILS_PAGE.detail.askSubject',
1060
+ body: 'DETAILS_PAGE.detail.askBody',
1061
+ objectWithInventory: 'DETAILS_PAGE.detail.askObjectWithInventory',
1062
+ portal: 'MODEL.mainTitle',
1063
+ };
1064
+ const ASK_ABOUT_OBJECT_EMAIL = /^[^\s@?&#]+@[^\s@?&#]+\.[^\s@?&#]+$/;
1065
+ function askAboutObjectTranslated(translate, key, params) {
1066
+ const value = translate(key, params);
1067
+ return value && value !== key ? value : null;
1068
+ }
1069
+ /**
1070
+ * The `mailto:` of the „Zapytaj o obiekt” button: the address from
1071
+ * `askEmail`, the subject with the title (and the inventory number), the body
1072
+ * with a greeting, the object, a link and room for the question. Without a
1073
+ * valid address — `null` (no button); a missing subject or body is simply
1074
+ * skipped. Body lines break as CRLF, as RFC 6068 requires.
1075
+ */
1076
+ function askAboutObjectHref(translate, object) {
1077
+ const email = askAboutObjectTranslated(translate, ASK_ABOUT_OBJECT_KEYS.email)?.trim();
1078
+ if (!email || !ASK_ABOUT_OBJECT_EMAIL.test(email))
1079
+ return null;
1080
+ const title = object.title.trim();
1081
+ const inventory = object.inventory?.trim();
1082
+ const label = (inventory &&
1083
+ askAboutObjectTranslated(translate, ASK_ABOUT_OBJECT_KEYS.objectWithInventory, {
1084
+ title,
1085
+ inventory,
1086
+ })) ||
1087
+ title;
1088
+ const subject = askAboutObjectTranslated(translate, ASK_ABOUT_OBJECT_KEYS.subject, {
1089
+ object: label,
1090
+ });
1091
+ const body = askAboutObjectTranslated(translate, ASK_ABOUT_OBJECT_KEYS.body, {
1092
+ object: label,
1093
+ url: object.url ?? '',
1094
+ portal: askAboutObjectTranslated(translate, ASK_ABOUT_OBJECT_KEYS.portal) ?? '',
1095
+ });
1096
+ const query = [
1097
+ subject ? `subject=${encodeURIComponent(subject)}` : null,
1098
+ body ? `body=${encodeURIComponent(body.replace(/\r?\n/g, '\r\n'))}` : null,
1099
+ ].filter(Boolean);
1100
+ return `mailto:${email}${query.length ? `?${query.join('&')}` : ''}`;
1101
+ }
1102
+
567
1103
  /**
568
1104
  * Generated bundle index. Do not edit.
569
1105
  */
570
1106
 
571
- export { AdvancedFilterType, ComponentsModule, FiltersComponent, FormFeatureType, ListComponent, MULTIMEDIA_DICTIONARY, OBJECTS_CATALOG_DEFAULTS, OBJECTS_CATALOG_SEO, ObjectsCatalogBaseComponent, ObjectsFacade, ObjectsItemComponent, ObjectsListComponent, ObjectsModule, ObjectsService, ObjectsSharedModule, SERVICES, getObjectsState, getReducer, initialState };
1107
+ export { ASK_ABOUT_OBJECT_KEYS, AdvancedFilterType, ComponentsModule, FiltersComponent, FormFeatureType, ListComponent, MULTIMEDIA_DICTIONARY, OBJECTS_CATALOG_DEFAULTS, OBJECTS_CATALOG_SEO, ObjectDetailsBaseComponent, ObjectMediaService, ObjectsCatalogBaseComponent, ObjectsDataService, ObjectsFacade, ObjectsItemComponent, ObjectsListComponent, ObjectsModule, ObjectsService, ObjectsSharedModule, SERVICES, askAboutObjectHref, bibliography, descriptionHtml, getObjectsState, getReducer, initialState, inventoryValues, objectTitle, relatedArticles, tagValues };
572
1108
  //# sourceMappingURL=smartsoft001-mobilems-objects-shell-angular.mjs.map