@smartsoft001-mobilems/objects-shell-angular 2.125.0 → 2.126.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,
|
|
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 (`<p>`), 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> </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
|