@smartsoft001-mobilems/objects-shell-angular 2.128.0 → 2.130.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,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, inject, contentChild, ChangeDetectionStrategy, Component, NgModule, PLATFORM_ID, isDevMode, Directive, signal, effect, viewChild, computed, untracked } from '@angular/core';
2
+ import { Injectable, inject, contentChild, ChangeDetectionStrategy, Component, NgModule, PLATFORM_ID, isDevMode, Directive, signal, effect, viewChild, computed, untracked, input, output, linkedSignal, DestroyRef } 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, DICTIONARY_PAGE_SIZE, FileUrlService, provideInheritedSharedConfig, SharedModule, SeoService, TranslationService, MuseumSeoService, WcagChangesService, createSafeTimeouts, MasonryGridComponent, createCatalog, createCatalogLoadMore, toPlainText, namedEntryParts } from '@smartsoft001-mobilems/angular';
5
+ import { CrudBaseService, FiltersBaseComponent, DICTIONARY_PAGE_SIZE, FileUrlService, provideInheritedSharedConfig, SharedModule, SeoService, TranslationService, MuseumSeoService, WcagChangesService, createSafeTimeouts, MasonryGridComponent, createCatalog, createCatalogLoadMore, toPlainText, namedEntryParts, serializeCatalogFilter, createCycleTagSearch, mergeSelectedTags, dictionaryFilterItems, dictionaryEntryKey, dictionaryEntries, SHARE_LINK_COPIED_TIMEOUT, ShareLinkBaseComponent, createLazyLoader } from '@smartsoft001-mobilems/angular';
6
6
  import { NgTemplateOutlet, CommonModule, isPlatformServer, isPlatformBrowser, DOCUMENT } from '@angular/common';
7
7
  import { firstValueFrom, retry, timer, throwError, map, tap, catchError, of, expand, reduce, switchMap, from, mergeMap, toArray, filter, takeUntil } from 'rxjs';
8
8
  import { createFeatureSelector } from '@ngrx/store';
@@ -10,6 +10,7 @@ import { toSignal, toObservable, rxResource } from '@angular/core/rxjs-interop';
10
10
  import { ActivatedRoute } from '@angular/router';
11
11
  import { TranslateService } from '@ngx-translate/core';
12
12
  import { capitalize } from '@smartsoft001/utils';
13
+ import { DomSanitizer } from '@angular/platform-browser';
13
14
 
14
15
  class ObjectsService extends CrudBaseService {
15
16
  getUrlNameForDetails() {
@@ -1100,9 +1101,1642 @@ function askAboutObjectHref(translate, object) {
1100
1101
  return `mailto:${email}${query.length ? `?${query.join('&')}` : ''}`;
1101
1102
  }
1102
1103
 
1104
+ /** Translation keys of the "Pokaż tylko" multimedia entries, by `FormFeatureType`. */
1105
+ const OBJECTS_ADVANCED_FILTERS_MULTIMEDIA_LABELS = {
1106
+ [FormFeatureType.IMAGES]: 'OBJECTS.filters.multimedia.images',
1107
+ [FormFeatureType.VIDEO]: 'OBJECTS.filters.multimedia.video',
1108
+ };
1109
+ /**
1110
+ * Logic of the "Filtry zaawansowane" panel (Muzeum Podlasia's
1111
+ * `objects-advanced-filters`, MOB-1596, layout per the MOB-1802 mockup).
1112
+ * Presentational: reads `filter()`, keeps every edit in a local draft and only
1113
+ * writes through `apply` — the parent (the filters bar) performs the single
1114
+ * `context.filters` assignment. The template and the field config stay in the app.
1115
+ */
1116
+ class ObjectsAdvancedFiltersBaseComponent {
1117
+ constructor(options) {
1118
+ this.objectsAdvancedFiltersTranslate = inject(TranslateService);
1119
+ this.objectsAdvancedFiltersService = inject(ObjectsDataService);
1120
+ /**
1121
+ * "Kolekcja tematyczna" (MOB-1844): pages of the `public-collections` list
1122
+ * (with the phrase in `filter[phrase]`) instead of a `dictionary/*` dictionary.
1123
+ */
1124
+ this.collectionsSource = (page, query) => this.objectsAdvancedFiltersService.loadCollectionsDictionary(page, query);
1125
+ /**
1126
+ * "Filtry archeologiczne" (MOB-1844): the `ArchaeologicalData` dictionary
1127
+ * with a segregation — names planned by MobileMS (Kanbanize 50040).
1128
+ */
1129
+ this.archeoTownsSource = (page, query) => this.objectsAdvancedFiltersService.loadArchaeologicalDictionary('ArcheoTown', page, query);
1130
+ this.archeoPeriodsSource = (page, query) => this.objectsAdvancedFiltersService.loadArchaeologicalDictionary('ArcheoPeriod', page, query);
1131
+ this.archeoCulturesSource = (page, query) => this.objectsAdvancedFiltersService.loadArchaeologicalDictionary('ArcheoCulture', page, query);
1132
+ this.filter = input.required(/* @ts-ignore */
1133
+ ...(ngDevMode ? [{ debugName: "filter" }] : /* istanbul ignore next */ []));
1134
+ /** Toggled by the parent — reopening re-derives the draft, discarding it. */
1135
+ this.isOpen = input(false, /* @ts-ignore */
1136
+ ...(ngDevMode ? [{ debugName: "isOpen" }] : /* istanbul ignore next */ []));
1137
+ /** The `/obiekty` list — a full-width band with the content in the page container. */
1138
+ this.fullBleed = input(false, /* @ts-ignore */
1139
+ ...(ngDevMode ? [{ debugName: "fullBleed" }] : /* istanbul ignore next */ []));
1140
+ this.apply = output();
1141
+ /** Mobile: the "Filtry archeologiczne" section collapses with a chevron (2276:7709). */
1142
+ this.archaeologicalOpen = signal(true, /* @ts-ignore */
1143
+ ...(ngDevMode ? [{ debugName: "archaeologicalOpen" }] : /* istanbul ignore next */ []));
1144
+ this.draftQuery = linkedSignal(() => {
1145
+ this.isOpen();
1146
+ return this.filter().query ? [...(this.filter().query ?? [])] : [];
1147
+ }, /* @ts-ignore */
1148
+ ...(ngDevMode ? [{ debugName: "draftQuery" }] : /* istanbul ignore next */ []));
1149
+ /** Synthetic filter fed to the reused `app-filter-*` inputs so they read the draft. */
1150
+ this.draftFilter = computed(() => ({
1151
+ ...this.filter(),
1152
+ query: this.draftQuery(),
1153
+ }), /* @ts-ignore */
1154
+ ...(ngDevMode ? [{ debugName: "draftFilter" }] : /* istanbul ignore next */ []));
1155
+ this.objectsAdvancedFiltersOptions = {
1156
+ fields: options.fields,
1157
+ managedKeys: options.managedKeys,
1158
+ retiredKeys: options.retiredKeys ?? [],
1159
+ onExhibitionValue: options.onExhibitionValue ?? 'Ekspozycja',
1160
+ };
1161
+ this.fields = options.fields;
1162
+ }
1163
+ readDictionaryValue(key) {
1164
+ const value = this.draftQuery().find((q) => q.key === key)?.value;
1165
+ return Array.isArray(value) ? value : [];
1166
+ }
1167
+ onDictionaryToggle(key, item) {
1168
+ const current = this.readDictionaryValue(key);
1169
+ const isPresent = current.some((i) => i.id === item.id);
1170
+ const next = isPresent
1171
+ ? current.filter((i) => i.id !== item.id)
1172
+ : [...current, item];
1173
+ this.objectsAdvancedFiltersWriteDictionary(key, next);
1174
+ // Picking a department means "Na ekspozycji: Tak" (MOB-1802) — the field
1175
+ // is enabled from the start and the checkbox follows the choice.
1176
+ const field = this.fields.find((f) => f.key === key);
1177
+ if (field?.dependsOn === 'onExhibition' && next.length) {
1178
+ this.objectsAdvancedFiltersSetDraftEntry('locationType', this.objectsAdvancedFiltersOptions.onExhibitionValue);
1179
+ }
1180
+ }
1181
+ onDictionaryClearAll(key) {
1182
+ this.objectsAdvancedFiltersWriteDictionary(key, []);
1183
+ }
1184
+ toggleArchaeological() {
1185
+ this.archaeologicalOpen.update((open) => !open);
1186
+ }
1187
+ onInventoryNumberChange(value) {
1188
+ this.objectsAdvancedFiltersSetDraftEntry('inventoryNumber', value.length ? value : undefined);
1189
+ }
1190
+ onBooleanToggle(key, checked) {
1191
+ if (key === 'onExhibition') {
1192
+ this.objectsAdvancedFiltersSetDraftEntry('locationType', checked
1193
+ ? this.objectsAdvancedFiltersOptions.onExhibitionValue
1194
+ : undefined);
1195
+ if (!checked) {
1196
+ this.onDictionaryClearAll('department');
1197
+ }
1198
+ return;
1199
+ }
1200
+ const featureId = key === 'hasImage' ? FormFeatureType.IMAGES : FormFeatureType.VIDEO;
1201
+ this.objectsAdvancedFiltersToggleFormFeature(featureId);
1202
+ }
1203
+ isBooleanChecked(key) {
1204
+ if (key === 'onExhibition') {
1205
+ return this.draftQuery().some((q) => q.key === 'locationType' &&
1206
+ q.value === this.objectsAdvancedFiltersOptions.onExhibitionValue);
1207
+ }
1208
+ const featureId = key === 'hasImage' ? FormFeatureType.IMAGES : FormFeatureType.VIDEO;
1209
+ return this.readDictionaryValue('formFeature').some((item) => item.id === featureId);
1210
+ }
1211
+ applyDraft() {
1212
+ this.apply.emit(this.objectsAdvancedFiltersBuildPayload());
1213
+ }
1214
+ reset() {
1215
+ this.draftQuery.set([]);
1216
+ this.apply.emit(this.objectsAdvancedFiltersBuildPayload());
1217
+ }
1218
+ objectsAdvancedFiltersToggleFormFeature(featureId) {
1219
+ const current = this.readDictionaryValue('formFeature');
1220
+ const isPresent = current.some((item) => item.id === featureId);
1221
+ if (isPresent) {
1222
+ this.objectsAdvancedFiltersWriteDictionary('formFeature', current.filter((item) => item.id !== featureId));
1223
+ return;
1224
+ }
1225
+ const labelKey = OBJECTS_ADVANCED_FILTERS_MULTIMEDIA_LABELS[featureId];
1226
+ const name = labelKey
1227
+ ? this.objectsAdvancedFiltersTranslate.instant(labelKey)
1228
+ : '';
1229
+ this.objectsAdvancedFiltersWriteDictionary('formFeature', [
1230
+ ...current,
1231
+ { id: featureId, name },
1232
+ ]);
1233
+ }
1234
+ objectsAdvancedFiltersWriteDictionary(key, items) {
1235
+ this.objectsAdvancedFiltersSetDraftEntry(key, (items.length ? items : undefined));
1236
+ }
1237
+ objectsAdvancedFiltersSetDraftEntry(key, value) {
1238
+ this.draftQuery.update((current) => {
1239
+ const filtered = current.filter((q) => q.key !== key);
1240
+ return value === undefined
1241
+ ? filtered
1242
+ : [...filtered, { key, type: '=', value }];
1243
+ });
1244
+ }
1245
+ objectsAdvancedFiltersBuildPayload() {
1246
+ const { managedKeys, retiredKeys } = this.objectsAdvancedFiltersOptions;
1247
+ return [
1248
+ ...managedKeys.map((key) => ({
1249
+ key,
1250
+ type: '=',
1251
+ val: this.draftQuery().find((q) => q.key === key)?.value,
1252
+ })),
1253
+ ...retiredKeys
1254
+ .filter((key) => !managedKeys.includes(key))
1255
+ .map((key) => ({ key, type: '=', val: undefined })),
1256
+ ];
1257
+ }
1258
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsAdvancedFiltersBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
1259
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectsAdvancedFiltersBaseComponent, isStandalone: true, inputs: { filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: true, transformFunction: null }, isOpen: { classPropertyName: "isOpen", publicName: "isOpen", isSignal: true, isRequired: false, transformFunction: null }, fullBleed: { classPropertyName: "fullBleed", publicName: "fullBleed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { apply: "apply" }, ngImport: i0 }); }
1260
+ }
1261
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsAdvancedFiltersBaseComponent, decorators: [{
1262
+ type: Directive
1263
+ }], ctorParameters: () => [{ type: undefined }], propDecorators: { filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: true }] }], isOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "isOpen", required: false }] }], fullBleed: [{ type: i0.Input, args: [{ isSignal: true, alias: "fullBleed", required: false }] }], apply: [{ type: i0.Output, args: ["apply"] }] } });
1264
+
1265
+ /** Sort options of the objects list (the default order). */
1266
+ const OBJECTS_SORT_OPTIONS = Object.freeze([
1267
+ { key: 'random', value: 'OBJECTS.filters.random' },
1268
+ { key: 'publication-date-desc', value: 'OBJECTS.filters.date-desc' },
1269
+ { key: 'publication-date-asc', value: 'OBJECTS.filters.date-asc' },
1270
+ { key: 'name-asc', value: 'OBJECTS.filters.name-asc' },
1271
+ { key: 'name-desc', value: 'OBJECTS.filters.name-desc' },
1272
+ { key: 'visited-desc', value: 'OBJECTS.filters.most-popular' },
1273
+ ]);
1274
+ /** Sort order shown for `?type=new` — newest first. */
1275
+ const OBJECTS_NEW_TYPE_SORT_OPTIONS = Object.freeze([
1276
+ { key: 'publication-date-desc', value: 'OBJECTS.filters.date-desc' },
1277
+ { key: 'publication-date-asc', value: 'OBJECTS.filters.date-asc' },
1278
+ { key: 'random', value: 'OBJECTS.filters.random' },
1279
+ { key: 'name-asc', value: 'OBJECTS.filters.name-asc' },
1280
+ { key: 'name-desc', value: 'OBJECTS.filters.name-desc' },
1281
+ { key: 'visited-desc', value: 'OBJECTS.filters.most-popular' },
1282
+ ]);
1283
+ /** Page sizes of the objects list. */
1284
+ const OBJECTS_LIMIT_OPTIONS = Object.freeze([
1285
+ { key: '20' },
1286
+ { key: '40' },
1287
+ { key: '60' },
1288
+ { key: '80' },
1289
+ { key: '100' },
1290
+ ]);
1291
+ /** API query key of the theme tags (cycles) filter. */
1292
+ const OBJECTS_THEME_TAGS_FILTER_KEY = 'cycles';
1293
+ /** Chip labels of the yes/no filters from the advanced filters panel. */
1294
+ const OBJECTS_FILTER_SINGLE_VALUE_LABELS = {
1295
+ onExhibition: 'SEARCH.advancedFiltersPanel.onExhibition',
1296
+ hasImage: 'SEARCH.advancedFiltersPanel.hasImage',
1297
+ hasVideo: 'SEARCH.advancedFiltersPanel.hasVideo',
1298
+ };
1299
+ /** The sort dropdown key (e.g. `publication-date-desc`) of a sort; no `by` → `random`. */
1300
+ function objectsSortKey(sort) {
1301
+ return !sort?.by ? 'random' : `${sort.by}-${sort.desc ? 'desc' : 'asc'}`;
1302
+ }
1303
+ /** The sort of a sort dropdown key; `random` → an empty `by`. */
1304
+ function objectsSortFromKey(key) {
1305
+ const desc = key.endsWith('-desc');
1306
+ let by = key.replace(/-asc$/, '').replace(/-desc$/, '');
1307
+ if (by === 'random') {
1308
+ by = '';
1309
+ }
1310
+ return { by, desc };
1311
+ }
1312
+ /** The list URL (`/obiekty` by default) restoring the current filter (phrase + query items). */
1313
+ function buildObjectsResultsUrl(origin, filter, listPath = '/obiekty') {
1314
+ const serialized = serializeCatalogFilter(filter);
1315
+ return `${origin}${listPath}${serialized ? `?filter=${encodeURIComponent(serialized)}` : ''}`;
1316
+ }
1317
+
1318
+ /** API query key of the "Nr inwentarzowy" filter — `filter[inventoryNumber]`. */
1319
+ const OBJECTS_FILTERS_BAR_INVENTORY_NUMBER_KEY = 'inventoryNumber';
1320
+ const OBJECTS_FILTERS_BAR_SINGLE_VALUE_KEYS = [
1321
+ 'categories',
1322
+ OBJECTS_FILTERS_BAR_INVENTORY_NUMBER_KEY,
1323
+ 'locationType',
1324
+ ];
1325
+ /** `val[0]` upper-cased; `''` for an empty value. */
1326
+ function objectsFiltersBarCapitalize(val) {
1327
+ if (!val?.length)
1328
+ return '';
1329
+ return val[0].toUpperCase() + val.substring(1);
1330
+ }
1331
+ /**
1332
+ * Stand-in `FiltersContext` of the search results tab (MOB-1818): the same
1333
+ * setters as the framework context (`filters`, `sort`, `limit`), but instead of
1334
+ * a read through the facade it hands over the complete filter — the list moves
1335
+ * to `/obiekty` with it. Query items are composed like
1336
+ * `FiltersContext.multipleFilterSet`: a write replaces the item with the same
1337
+ * key and type, `undefined` removes it.
1338
+ */
1339
+ class ObjectsRedirectFiltersContext {
1340
+ constructor(current, redirect) {
1341
+ this.current = current;
1342
+ this.redirect = redirect;
1343
+ }
1344
+ get query() {
1345
+ return this.current().query;
1346
+ }
1347
+ set filters(writes) {
1348
+ let query = [...(this.current().query ?? [])];
1349
+ for (const { key, type, val } of writes) {
1350
+ query = query.filter((item) => !(item.key === key && item.type === type));
1351
+ if (val) {
1352
+ query.push({
1353
+ key,
1354
+ type,
1355
+ value: val,
1356
+ hidden: true,
1357
+ });
1358
+ }
1359
+ }
1360
+ this.emit({ query, offset: 0 });
1361
+ }
1362
+ set sort(value) {
1363
+ this.emit({ sortBy: value.by, sortDesc: value.desc, offset: 0 });
1364
+ }
1365
+ set limit(value) {
1366
+ this.emit({ limit: value, offset: 0 });
1367
+ }
1368
+ emit(patch) {
1369
+ this.redirect({ ...this.current(), ...patch });
1370
+ }
1371
+ }
1372
+ /**
1373
+ * Logic of the objects list top filter bar (Muzeum Podlasia's `filters-top`):
1374
+ * theme tags, applied-filter chips, sort/limit dropdowns, pagination and the
1375
+ * grid/list view toggle. The template stays in the app.
1376
+ *
1377
+ * Every mutation goes through the framework `FiltersContext` provided by
1378
+ * `<smart-mobilems-objects-filters>` (passed to each method by the template),
1379
+ * which dispatches to the objects facade. With `redirectChanges` on (the search
1380
+ * results tab, MOB-1818) filter, sort and page size changes are emitted as one
1381
+ * complete filter through `filterRedirect` instead.
1382
+ */
1383
+ class ObjectsFiltersBarBaseComponent {
1384
+ constructor(options = {}) {
1385
+ this.objectsFiltersBarTranslate = inject(TranslateService);
1386
+ /** Query key of the panel's theme tags — `filter[cycles][]`. */
1387
+ this.themeTagsFilterKey = OBJECTS_THEME_TAGS_FILTER_KEY;
1388
+ /**
1389
+ * The page's current filter, bound straight through to the filter fields.
1390
+ * Reads come from here; writes go through the FiltersContext — both sides are
1391
+ * the same facade state.
1392
+ */
1393
+ this.filter = input.required(/* @ts-ignore */
1394
+ ...(ngDevMode ? [{ debugName: "filter" }] : /* istanbul ignore next */ []));
1395
+ this.listMode = input(ListMode.masonryGrid, /* @ts-ignore */
1396
+ ...(ngDevMode ? [{ debugName: "listMode" }] : /* istanbul ignore next */ []));
1397
+ /**
1398
+ * The result count from the list page — only for the response to the
1399
+ * current filter (the shared slice guard). Without it the count comes from
1400
+ * the framework context.
1401
+ */
1402
+ this.totalCount = input(null, /* @ts-ignore */
1403
+ ...(ngDevMode ? [{ debugName: "totalCount" }] : /* istanbul ignore next */ []));
1404
+ this.sortOptions = input(OBJECTS_SORT_OPTIONS, /* @ts-ignore */
1405
+ ...(ngDevMode ? [{ debugName: "sortOptions" }] : /* istanbul ignore next */ []));
1406
+ /**
1407
+ * The search results tab (MOB-1818): filter, sort and page size changes do
1408
+ * not narrow the list in place, they go to `filterRedirect` with the
1409
+ * complete filter. Paging stays in the tab.
1410
+ */
1411
+ this.redirectChanges = input(false, /* @ts-ignore */
1412
+ ...(ngDevMode ? [{ debugName: "redirectChanges" }] : /* istanbul ignore next */ []));
1413
+ /** The complete filter after a change in the results tab — the list moves to `/obiekty` with it. */
1414
+ this.filterRedirect = output();
1415
+ this.listModeChange = output();
1416
+ /** Free-text search — the list page owns the read so it can persist it. */
1417
+ this.searchEvent = output();
1418
+ this.isAdvancedFiltersOn = signal(false, /* @ts-ignore */
1419
+ ...(ngDevMode ? [{ debugName: "isAdvancedFiltersOn" }] : /* istanbul ignore next */ []));
1420
+ this.limitOptions = OBJECTS_LIMIT_OPTIONS;
1421
+ this.objectsFiltersBarSingleValueLabels =
1422
+ options.singleValueLabels ?? OBJECTS_FILTER_SINGLE_VALUE_LABELS;
1423
+ this.objectsFiltersBarSingleValueKeys =
1424
+ options.singleValueKeys ?? OBJECTS_FILTERS_BAR_SINGLE_VALUE_KEYS;
1425
+ // The whole `Cycle` dictionary (every page), with the hit phrase `q`
1426
+ // (MOB-1826, like the collections and articles lists). How many show at
1427
+ // once is the panel's call (6 + "Pokaż wszystkie").
1428
+ this.objectsFiltersBarCycleTags = options.cycleTags
1429
+ ? createCycleTagSearch()
1430
+ : { query: signal(''), tags: signal([]) };
1431
+ this.tagsQuery = this.objectsFiltersBarCycleTags.query;
1432
+ const cycleTags = this.objectsFiltersBarCycleTags.tags;
1433
+ this.themeTags = computed(() => mergeSelectedTags(dictionaryFilterItems(this.filter()?.query?.find((q) => q.key === OBJECTS_THEME_TAGS_FILTER_KEY && q.type === '=')?.value), cycleTags()), /* @ts-ignore */
1434
+ ...(ngDevMode ? [{ debugName: "themeTags" }] : /* istanbul ignore next */ []));
1435
+ }
1436
+ toggleAdvancedFilters(force) {
1437
+ this.isAdvancedFiltersOn.update((val) => force ?? !val);
1438
+ }
1439
+ onListMode(value) {
1440
+ this.listModeChange.emit(value);
1441
+ }
1442
+ /** Map a sort dropdown key (e.g. `publication-date-desc`) to the matching option. */
1443
+ selectedSort(sort) {
1444
+ const options = this.sortOptions();
1445
+ const key = objectsSortKey(sort);
1446
+ return options.find((option) => option.key === key) ?? options[0];
1447
+ }
1448
+ selectedLimit(limit) {
1449
+ return (this.limitOptions.find((option) => option.key === String(limit ?? 20)) ??
1450
+ this.limitOptions[0]);
1451
+ }
1452
+ sortChange(context, value) {
1453
+ this.objectsFiltersBarWriteTarget(context).sort = objectsSortFromKey(value);
1454
+ }
1455
+ limitChange(context, value) {
1456
+ this.objectsFiltersBarWriteTarget(context).limit = Number(value);
1457
+ }
1458
+ pageChange(context, value) {
1459
+ context.page = value;
1460
+ }
1461
+ /** Toggle a single dictionary entry on/off for `key`. */
1462
+ advancedFilterChange(context, entry, key) {
1463
+ // `"3"` from a deep link and `3` from the dictionary are the same entry —
1464
+ // a click removes it instead of adding a duplicate; an event without an id
1465
+ // changes nothing (MOB-1826 review).
1466
+ const eventKey = dictionaryEntryKey(entry?.id);
1467
+ if (eventKey === null) {
1468
+ return;
1469
+ }
1470
+ const current = this.objectsFiltersBarDictionaryValue(context, key);
1471
+ const isToggled = (item) => dictionaryEntryKey(item.id) === eventKey;
1472
+ const newValue = current.some(isToggled)
1473
+ ? current.filter((item) => !isToggled(item))
1474
+ : [...current, entry];
1475
+ this.objectsFiltersBarSetDictionary(context, key, newValue);
1476
+ }
1477
+ /** The year field already debounces typing (500 ms, MOB-1866). */
1478
+ onCreateDatesFilterChange(context, range) {
1479
+ // Set both bounds in one go so the list is re-read once, not twice.
1480
+ this.objectsFiltersBarWriteTarget(context).filters = [
1481
+ {
1482
+ val: (range.from?.length ? range.from : undefined),
1483
+ key: 'createDates',
1484
+ type: '>=',
1485
+ },
1486
+ {
1487
+ val: (range.to?.length ? range.to : undefined),
1488
+ key: 'createDates',
1489
+ type: '<=',
1490
+ },
1491
+ ];
1492
+ }
1493
+ /**
1494
+ * Batched write from the "Filtry zaawansowane" panel (MOB-1596) — the panel
1495
+ * keeps its own draft and hands over the full snapshot on Zastosuj/Resetuj,
1496
+ * so this is the single `context.filters` assignment for that whole edit.
1497
+ */
1498
+ onAdvancedFiltersApply(context, payload) {
1499
+ this.objectsFiltersBarWriteTarget(context).filters =
1500
+ payload;
1501
+ }
1502
+ /** A yes/no filter — unchecking sends `undefined`, which removes the query item. */
1503
+ onBooleanFilterChange(context, event) {
1504
+ this.objectsFiltersBarWriteTarget(context).filters = [
1505
+ {
1506
+ // `FilterType` has no boolean; a yes/no filter writes `true`.
1507
+ val: (event.checked ? true : undefined),
1508
+ key: event.key,
1509
+ type: '=',
1510
+ },
1511
+ ];
1512
+ }
1513
+ clearFilter(context, item, value) {
1514
+ if (!item) {
1515
+ return;
1516
+ }
1517
+ // The date range and single-value filters (also the panel's yes/no ones,
1518
+ // e.g. "Z opisem") have no list of values — the chip clears the whole item.
1519
+ if ((item.key === 'createDates' &&
1520
+ (item.type === '>=' || item.type === '<=')) ||
1521
+ this.objectsFiltersBarSingleValueKeys.includes(item.key) ||
1522
+ !Array.isArray(item.value)) {
1523
+ this.objectsFiltersBarWriteTarget(context).filters = [
1524
+ { val: undefined, key: item.key, type: item.type },
1525
+ ];
1526
+ return;
1527
+ }
1528
+ // Removing the last entry of a dictionary filter drops the whole query item.
1529
+ if (value) {
1530
+ this.advancedFilterChange(context, value, item.key);
1531
+ }
1532
+ }
1533
+ isListValue(value) {
1534
+ return Array.isArray(value);
1535
+ }
1536
+ /** List entries for the chips — no `null` and no entries without an id (MOB-1826 review). */
1537
+ listEntries(value) {
1538
+ return dictionaryEntries(value);
1539
+ }
1540
+ /** Chip label of a filter without a list of values — a yes/no one has no value after the colon. */
1541
+ singleValueLabel(item) {
1542
+ const label = objectsFiltersBarCapitalize(this.objectsFiltersBarTranslate.instant(this.objectsFiltersBarSingleValueLabels[item.key] ??
1543
+ `OBJECTS.filters.applied.${item.key}`));
1544
+ return item.value === true ? label : `${label}: ${item.value}`;
1545
+ }
1546
+ $context(context) {
1547
+ return context;
1548
+ }
1549
+ /**
1550
+ * Write a dictionary filter through the generic `filters` setter. An empty
1551
+ * selection must be sent as `undefined` — that is what removes the query item.
1552
+ */
1553
+ objectsFiltersBarSetDictionary(context, key, value) {
1554
+ this.objectsFiltersBarWriteTarget(context).filters = [
1555
+ { val: (value.length ? value : undefined), key, type: '=' },
1556
+ ];
1557
+ }
1558
+ /**
1559
+ * Where a filter write goes: the framework context (read through the facade)
1560
+ * or — in the results tab — the redirect context to `/obiekty` (MOB-1818).
1561
+ */
1562
+ objectsFiltersBarWriteTarget(context) {
1563
+ if (!this.redirectChanges()) {
1564
+ return context;
1565
+ }
1566
+ return new ObjectsRedirectFiltersContext(() => this.filter(), (filter) => this.filterRedirect.emit(filter));
1567
+ }
1568
+ objectsFiltersBarDictionaryValue(context, key) {
1569
+ const value = (context.query ?? []).find((q) => q.key === key && q.type === '=')?.value;
1570
+ return dictionaryEntries(value);
1571
+ }
1572
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsFiltersBarBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
1573
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectsFiltersBarBaseComponent, isStandalone: true, inputs: { filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: true, transformFunction: null }, listMode: { classPropertyName: "listMode", publicName: "listMode", isSignal: true, isRequired: false, transformFunction: null }, totalCount: { classPropertyName: "totalCount", publicName: "totalCount", isSignal: true, isRequired: false, transformFunction: null }, sortOptions: { classPropertyName: "sortOptions", publicName: "sortOptions", isSignal: true, isRequired: false, transformFunction: null }, redirectChanges: { classPropertyName: "redirectChanges", publicName: "redirectChanges", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filterRedirect: "filterRedirect", listModeChange: "listModeChange", searchEvent: "searchEvent" }, ngImport: i0 }); }
1574
+ }
1575
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsFiltersBarBaseComponent, decorators: [{
1576
+ type: Directive
1577
+ }], ctorParameters: () => [{ type: undefined }], propDecorators: { filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: true }] }], listMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "listMode", required: false }] }], totalCount: [{ type: i0.Input, args: [{ isSignal: true, alias: "totalCount", required: false }] }], sortOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "sortOptions", required: false }] }], redirectChanges: [{ type: i0.Input, args: [{ isSignal: true, alias: "redirectChanges", required: false }] }], filterRedirect: [{ type: i0.Output, args: ["filterRedirect"] }], listModeChange: [{ type: i0.Output, args: ["listModeChange"] }], searchEvent: [{ type: i0.Output, args: ["searchEvent"] }] } });
1578
+
1579
+ /**
1580
+ * Logic of the grey objects search panel (Muzeum Podlasia's
1581
+ * `objects-search-panel`, mockup "search tagi filtry" 458:7898, desktop
1582
+ * "Frame 502" 2300:51176, mobile "Panel wyszukiwarki" 2300:51195): phrase →
1583
+ * theme tags + "Wyszukaj więcej tagów" → date range, "Pokaż" and "Szukaj" →
1584
+ * advanced filters. On mobile a "Filtry ↓" button under the phrase field
1585
+ * expands the rest of the panel.
1586
+ *
1587
+ * Presentational — it reads its state from `filter()` and emits events; the
1588
+ * parent (the filters bar) writes to `FiltersContext`. No template: each
1589
+ * museum extends it and keeps its own HTML, with the phrase input marked
1590
+ * `#phraseField`.
1591
+ */
1592
+ class ObjectsSearchPanelBaseComponent {
1593
+ constructor() {
1594
+ this.phrase = input('', /* @ts-ignore */
1595
+ ...(ngDevMode ? [{ debugName: "phrase" }] : /* istanbul ignore next */ []));
1596
+ this.filter = input.required(/* @ts-ignore */
1597
+ ...(ngDevMode ? [{ debugName: "filter" }] : /* istanbul ignore next */ []));
1598
+ /**
1599
+ * Theme tags — the whole `Cycle` dictionary or the "Wyszukaj więcej tagów"
1600
+ * result; the template decides how many show at once (MOB-1826).
1601
+ */
1602
+ this.tags = input([], /* @ts-ignore */
1603
+ ...(ngDevMode ? [{ debugName: "tags" }] : /* istanbul ignore next */ []));
1604
+ /** The parent controls the advanced filters panel state. */
1605
+ this.isAdvancedFiltersOn = input(false, /* @ts-ignore */
1606
+ ...(ngDevMode ? [{ debugName: "isAdvancedFiltersOn" }] : /* istanbul ignore next */ []));
1607
+ /**
1608
+ * The `/obiekty` list also searches by filters alone — "Szukaj" with an
1609
+ * empty phrase is allowed there. In the search results the phrase is part
1610
+ * of the address, so an empty phrase has nowhere to go.
1611
+ */
1612
+ this.allowEmptyPhrase = input(false, /* @ts-ignore */
1613
+ ...(ngDevMode ? [{ debugName: "allowEmptyPhrase" }] : /* istanbul ignore next */ []));
1614
+ /** A full-bleed band (the `/obiekty` list) instead of a grey box (search results). */
1615
+ this.fullBleed = input(false, /* @ts-ignore */
1616
+ ...(ngDevMode ? [{ debugName: "fullBleed" }] : /* istanbul ignore next */ []));
1617
+ /**
1618
+ * Selected filter chips (state "Selected" 2193:3301 / mobile 2276:7726) —
1619
+ * rendered by the parent and inserted once by the panel; only CSS places
1620
+ * them, so the DOM is the same after SSR and in the browser.
1621
+ */
1622
+ this.selectedFiltersTpl = input(null, /* @ts-ignore */
1623
+ ...(ngDevMode ? [{ debugName: "selectedFiltersTpl" }] : /* istanbul ignore next */ []));
1624
+ this.searchPhrase = output();
1625
+ this.tagToggle = output();
1626
+ /**
1627
+ * Passed through unchanged — the date field debounces the year typing
1628
+ * (MOB-1866), the panel delays nothing here.
1629
+ */
1630
+ this.dateRangeChange = output();
1631
+ this.booleanFilterChange = output();
1632
+ /** "Wyszukaj więcej tagów" — an empty phrase goes back to the default tags. */
1633
+ this.tagsSearch = output();
1634
+ this.advancedFiltersToggle = output();
1635
+ /** One batched payload from the "Filtry zaawansowane" panel's Zastosuj/Resetuj. */
1636
+ this.advancedFiltersApply = output();
1637
+ /** The selected theme tags read from the current filter. */
1638
+ this.selectedTagIds = computed(() => {
1639
+ const value = (this.filter()?.query ?? []).find((q) => q.key === OBJECTS_THEME_TAGS_FILTER_KEY)?.value;
1640
+ // `null`, `[null]` and entries without an id (a restored filter) are
1641
+ // skipped, `"3"` from the address = `3` from the dictionary (MOB-1826 review).
1642
+ return dictionaryFilterItems(value).map((item) => item.id);
1643
+ }, /* @ts-ignore */
1644
+ ...(ngDevMode ? [{ debugName: "selectedTagIds" }] : /* istanbul ignore next */ []));
1645
+ /** The field text — starts from `phrase()` and follows a change from the URL. */
1646
+ this.searchText = linkedSignal(() => this.phrase(), /* @ts-ignore */
1647
+ ...(ngDevMode ? [{ debugName: "searchText" }] : /* istanbul ignore next */ []));
1648
+ /** The "Wyszukaj więcej tagów" phrase and the last query sent. */
1649
+ this.tagQuery = signal('', /* @ts-ignore */
1650
+ ...(ngDevMode ? [{ debugName: "tagQuery" }] : /* istanbul ignore next */ []));
1651
+ this.submittedTagQuery = signal('', /* @ts-ignore */
1652
+ ...(ngDevMode ? [{ debugName: "submittedTagQuery" }] : /* istanbul ignore next */ []));
1653
+ /** "Pokaż wszystkie tagi (N)" expanded — a new search collapses it. */
1654
+ this.tagsExpanded = signal(false, /* @ts-ignore */
1655
+ ...(ngDevMode ? [{ debugName: "tagsExpanded" }] : /* istanbul ignore next */ []));
1656
+ /** Mobile: "Filtry ↓" expands the tags, dates and buttons (always visible from lg). */
1657
+ this.filtersOpen = signal(false, /* @ts-ignore */
1658
+ ...(ngDevMode ? [{ debugName: "filtersOpen" }] : /* istanbul ignore next */ []));
1659
+ /** Mobile: "Więcej tagów" reveals the tag search field (2276:7913). */
1660
+ this.moreTagsOpen = signal(false, /* @ts-ignore */
1661
+ ...(ngDevMode ? [{ debugName: "moreTagsOpen" }] : /* istanbul ignore next */ []));
1662
+ this.objectsSearchPanelPhraseField = viewChild('phraseField', /* @ts-ignore */
1663
+ ...(ngDevMode ? [{ debugName: "objectsSearchPanelPhraseField" }] : /* istanbul ignore next */ []));
1664
+ }
1665
+ /** The "Pokaż" boxes' state read from the filter — e.g. after a link with filters. */
1666
+ isBooleanFilterOn(key) {
1667
+ return (this.filter()?.query ?? []).some((q) => q.key === key && q.value === true);
1668
+ }
1669
+ toggleMoreTags() {
1670
+ this.moreTagsOpen.update((open) => !open);
1671
+ }
1672
+ toggleFilters() {
1673
+ this.filtersOpen.update((open) => !open);
1674
+ }
1675
+ /**
1676
+ * "×" in the phrase field. On the list it also clears the search; in the
1677
+ * search results it only empties the field (a new phrase is a new address).
1678
+ */
1679
+ clearPhrase() {
1680
+ this.searchText.set('');
1681
+ this.objectsSearchPanelPhraseField()?.nativeElement.focus();
1682
+ if (this.allowEmptyPhrase()) {
1683
+ this.searchPhrase.emit('');
1684
+ }
1685
+ }
1686
+ searchTags() {
1687
+ const query = this.tagQuery().trim();
1688
+ this.submittedTagQuery.set(query);
1689
+ this.tagsExpanded.set(false);
1690
+ this.tagsSearch.emit(query);
1691
+ }
1692
+ onBooleanFilterChange(key, checked) {
1693
+ this.booleanFilterChange.emit({ key, checked });
1694
+ }
1695
+ /**
1696
+ * Only an explicit action (button / Enter) searches — the panel has no
1697
+ * live search, so there is no debounce here either.
1698
+ */
1699
+ search() {
1700
+ const phrase = this.searchText().trim();
1701
+ if (!phrase && !this.allowEmptyPhrase()) {
1702
+ return;
1703
+ }
1704
+ this.searchPhrase.emit(phrase);
1705
+ }
1706
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsSearchPanelBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1707
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.1.3", type: ObjectsSearchPanelBaseComponent, isStandalone: true, inputs: { phrase: { classPropertyName: "phrase", publicName: "phrase", isSignal: true, isRequired: false, transformFunction: null }, filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: true, transformFunction: null }, tags: { classPropertyName: "tags", publicName: "tags", isSignal: true, isRequired: false, transformFunction: null }, isAdvancedFiltersOn: { classPropertyName: "isAdvancedFiltersOn", publicName: "isAdvancedFiltersOn", isSignal: true, isRequired: false, transformFunction: null }, allowEmptyPhrase: { classPropertyName: "allowEmptyPhrase", publicName: "allowEmptyPhrase", isSignal: true, isRequired: false, transformFunction: null }, fullBleed: { classPropertyName: "fullBleed", publicName: "fullBleed", isSignal: true, isRequired: false, transformFunction: null }, selectedFiltersTpl: { classPropertyName: "selectedFiltersTpl", publicName: "selectedFiltersTpl", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { searchPhrase: "searchPhrase", tagToggle: "tagToggle", dateRangeChange: "dateRangeChange", booleanFilterChange: "booleanFilterChange", tagsSearch: "tagsSearch", advancedFiltersToggle: "advancedFiltersToggle", advancedFiltersApply: "advancedFiltersApply" }, viewQueries: [{ propertyName: "objectsSearchPanelPhraseField", first: true, predicate: ["phraseField"], descendants: true, isSignal: true }], ngImport: i0 }); }
1708
+ }
1709
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsSearchPanelBaseComponent, decorators: [{
1710
+ type: Directive
1711
+ }], propDecorators: { phrase: [{ type: i0.Input, args: [{ isSignal: true, alias: "phrase", required: false }] }], filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: true }] }], tags: [{ type: i0.Input, args: [{ isSignal: true, alias: "tags", required: false }] }], isAdvancedFiltersOn: [{ type: i0.Input, args: [{ isSignal: true, alias: "isAdvancedFiltersOn", required: false }] }], allowEmptyPhrase: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowEmptyPhrase", required: false }] }], fullBleed: [{ type: i0.Input, args: [{ isSignal: true, alias: "fullBleed", required: false }] }], selectedFiltersTpl: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectedFiltersTpl", required: false }] }], searchPhrase: [{ type: i0.Output, args: ["searchPhrase"] }], tagToggle: [{ type: i0.Output, args: ["tagToggle"] }], dateRangeChange: [{ type: i0.Output, args: ["dateRangeChange"] }], booleanFilterChange: [{ type: i0.Output, args: ["booleanFilterChange"] }], tagsSearch: [{ type: i0.Output, args: ["tagsSearch"] }], advancedFiltersToggle: [{ type: i0.Output, args: ["advancedFiltersToggle"] }], advancedFiltersApply: [{ type: i0.Output, args: ["advancedFiltersApply"] }], objectsSearchPanelPhraseField: [{ type: i0.ViewChild, args: ['phraseField', { isSignal: true }] }] } });
1712
+
1713
+ /** How long (ms) the "link copied" message stays after a click. */
1714
+ const OBJECTS_SHARE_COPIED_TIMEOUT = SHARE_LINK_COPIED_TIMEOUT;
1715
+ /**
1716
+ * Logic of "Share search results" (mock-up "Action panel" 2300:51184): a link
1717
+ * to the objects list with the current filters, shared through
1718
+ * `ShareLinkBaseComponent` — the native share sheet, or a clipboard copy with a
1719
+ * "link copied" flag when the browser has no share sheet. The template stays in
1720
+ * the app.
1721
+ */
1722
+ class ObjectsShareResultsBaseComponent extends ShareLinkBaseComponent {
1723
+ constructor() {
1724
+ super(...arguments);
1725
+ this.filter = input(undefined, /* @ts-ignore */
1726
+ ...(ngDevMode ? [{ debugName: "filter" }] : /* istanbul ignore next */ []));
1727
+ /** The list path the link opens; overridable, read inside `share`. */
1728
+ this.objectsShareResultsListPath = '/obiekty';
1729
+ /** How long (ms) "copied" stays on; overridable, read inside `share`. */
1730
+ this.objectsShareResultsCopiedTimeout = OBJECTS_SHARE_COPIED_TIMEOUT;
1731
+ }
1732
+ get shareLinkCopiedTimeout() {
1733
+ return this.objectsShareResultsCopiedTimeout;
1734
+ }
1735
+ shareLinkUrl() {
1736
+ return buildObjectsResultsUrl(this.shareLinkDocument.defaultView?.location.origin ?? '', this.filter(), this.objectsShareResultsListPath);
1737
+ }
1738
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsShareResultsBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
1739
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectsShareResultsBaseComponent, isStandalone: true, inputs: { filter: { classPropertyName: "filter", publicName: "filter", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0 }); }
1740
+ }
1741
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectsShareResultsBaseComponent, decorators: [{
1742
+ type: Directive
1743
+ }], propDecorators: { filter: [{ type: i0.Input, args: [{ isSignal: true, alias: "filter", required: false }] }] } });
1744
+
1745
+ /**
1746
+ * Logic-only base of an object media tab (MOB-1801), from Muzeum Podlasia's
1747
+ * `components/tabs`: the `item` and `formFeatureTypes` inputs and `models` —
1748
+ * the object's `additionalImages` of the given form feature types, sorted by
1749
+ * `position`. The tab keeps its template, styles and `host`.
1750
+ *
1751
+ * `mediaTabFilterModels(models)` is a hook (identity by default) that a tab
1752
+ * overrides to narrow the list further.
1753
+ *
1754
+ * ```ts
1755
+ * export class OtherFilesTabComponent extends ObjectMediaTabBaseComponent<IAnimationObject> {}
1756
+ * ```
1757
+ */
1758
+ class ObjectMediaTabBaseComponent {
1759
+ constructor() {
1760
+ this.mediaTabFileUrl = inject(FileUrlService);
1761
+ this.mediaTabTranslate = inject(TranslateService);
1762
+ /** The object whose media the tab shows. */
1763
+ this.item = input.required(/* @ts-ignore */
1764
+ ...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
1765
+ /** The form feature types (`FormFeatureType`) the tab shows. */
1766
+ this.formFeatureTypes = input.required(/* @ts-ignore */
1767
+ ...(ngDevMode ? [{ debugName: "formFeatureTypes" }] : /* istanbul ignore next */ []));
1768
+ /** The object's media of `formFeatureTypes`, sorted by `position`, then through `mediaTabFilterModels`. */
1769
+ this.models = computed(() => {
1770
+ const obj = this.item();
1771
+ let filtered = [];
1772
+ if (obj.additionalImages && Array.isArray(obj.additionalImages)) {
1773
+ filtered = obj.additionalImages
1774
+ .filter((img) => this.formFeatureTypes().includes(img?.formFeature))
1775
+ .sort((a, b) => (a?.position ?? 0) - (b?.position ?? 0));
1776
+ }
1777
+ return this.mediaTabFilterModels(filtered);
1778
+ }, /* @ts-ignore */
1779
+ ...(ngDevMode ? [{ debugName: "models" }] : /* istanbul ignore next */ []));
1780
+ }
1781
+ /** Narrows the sorted list. Identity by default. */
1782
+ mediaTabFilterModels(models) {
1783
+ return models;
1784
+ }
1785
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaTabBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1786
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectMediaTabBaseComponent, isStandalone: true, inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null }, formFeatureTypes: { classPropertyName: "formFeatureTypes", publicName: "formFeatureTypes", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 }); }
1787
+ }
1788
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaTabBaseComponent, decorators: [{
1789
+ type: Directive
1790
+ }], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }], formFeatureTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "formFeatureTypes", required: true }] }] } });
1791
+
1792
+ /**
1793
+ * Logic-only base of the media tabs' download button, from Muzeum Podlasia's
1794
+ * `download-button`: `download()` fetches `url`, saves the blob as `filename`
1795
+ * through a temporary `<a download>` and revokes the object URL. It runs only
1796
+ * from a click, so never on the server. The button keeps its template.
1797
+ *
1798
+ * ```ts
1799
+ * export class DownloadButtonComponent extends ObjectDownloadButtonBaseComponent {}
1800
+ * ```
1801
+ */
1802
+ class ObjectDownloadButtonBaseComponent {
1803
+ constructor() {
1804
+ this.downloadButtonDocument = inject(DOCUMENT);
1805
+ /** The file URL. An empty URL does nothing. */
1806
+ this.url = input.required(/* @ts-ignore */
1807
+ ...(ngDevMode ? [{ debugName: "url" }] : /* istanbul ignore next */ []));
1808
+ /** The name the file is saved under. */
1809
+ this.filename = input.required(/* @ts-ignore */
1810
+ ...(ngDevMode ? [{ debugName: "filename" }] : /* istanbul ignore next */ []));
1811
+ /** `true` while the file is being fetched. */
1812
+ this.isDownloading = signal(false, /* @ts-ignore */
1813
+ ...(ngDevMode ? [{ debugName: "isDownloading" }] : /* istanbul ignore next */ []));
1814
+ }
1815
+ async download() {
1816
+ const url = this.url();
1817
+ const filename = this.filename();
1818
+ if (!url)
1819
+ return;
1820
+ this.isDownloading.set(true);
1821
+ try {
1822
+ const response = await fetch(url);
1823
+ const blob = await response.blob();
1824
+ const blobUrl = URL.createObjectURL(blob);
1825
+ const link = this.downloadButtonDocument.createElement('a');
1826
+ link.href = blobUrl;
1827
+ link.download = filename;
1828
+ link.click();
1829
+ URL.revokeObjectURL(blobUrl);
1830
+ }
1831
+ catch (error) {
1832
+ console.error('Failed to download file:', error);
1833
+ }
1834
+ finally {
1835
+ this.isDownloading.set(false);
1836
+ }
1837
+ }
1838
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectDownloadButtonBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1839
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectDownloadButtonBaseComponent, isStandalone: true, inputs: { url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: true, transformFunction: null }, filename: { classPropertyName: "filename", publicName: "filename", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 }); }
1840
+ }
1841
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectDownloadButtonBaseComponent, decorators: [{
1842
+ type: Directive
1843
+ }], propDecorators: { url: [{ type: i0.Input, args: [{ isSignal: true, alias: "url", required: true }] }], filename: [{ type: i0.Input, args: [{ isSignal: true, alias: "filename", required: true }] }] } });
1844
+
1845
+ /**
1846
+ * Logic-only base of the object map tab, from Muzeum Podlasia's `map-tab`:
1847
+ * the object's `archeoMap` coordinates. The tab keeps its template (the map
1848
+ * component and its zoom).
1849
+ *
1850
+ * ```ts
1851
+ * export class MapTabComponent extends ObjectMapTabBaseComponent {}
1852
+ * ```
1853
+ */
1854
+ class ObjectMapTabBaseComponent {
1855
+ constructor() {
1856
+ /** The object whose `archeoMap` the tab shows. */
1857
+ this.item = input.required(/* @ts-ignore */
1858
+ ...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
1859
+ this.mapTabArcheoMap = computed(() => {
1860
+ const obj = this.item();
1861
+ return obj?.archeoMap;
1862
+ }, /* @ts-ignore */
1863
+ ...(ngDevMode ? [{ debugName: "mapTabArcheoMap" }] : /* istanbul ignore next */ []));
1864
+ /** Truthy (the longitude string) when both coordinates are set. */
1865
+ this.hasCoordinates = computed(() => {
1866
+ const map = this.mapTabArcheoMap();
1867
+ return map?.latitude && map?.longitude;
1868
+ }, /* @ts-ignore */
1869
+ ...(ngDevMode ? [{ debugName: "hasCoordinates" }] : /* istanbul ignore next */ []));
1870
+ /** `parseFloat(latitude)`, `0` when missing. */
1871
+ this.latitude = computed(() => {
1872
+ const map = this.mapTabArcheoMap();
1873
+ return map?.latitude ? parseFloat(map.latitude) : 0;
1874
+ }, /* @ts-ignore */
1875
+ ...(ngDevMode ? [{ debugName: "latitude" }] : /* istanbul ignore next */ []));
1876
+ /** `parseFloat(longitude)`, `0` when missing. */
1877
+ this.longitude = computed(() => {
1878
+ const map = this.mapTabArcheoMap();
1879
+ return map?.longitude ? parseFloat(map.longitude) : 0;
1880
+ }, /* @ts-ignore */
1881
+ ...(ngDevMode ? [{ debugName: "longitude" }] : /* istanbul ignore next */ []));
1882
+ }
1883
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMapTabBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1884
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectMapTabBaseComponent, isStandalone: true, inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 }); }
1885
+ }
1886
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMapTabBaseComponent, decorators: [{
1887
+ type: Directive
1888
+ }], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }] } });
1889
+
1890
+ /**
1891
+ * Logic-only base of an object tab with native `<audio>` players (MOB-1801):
1892
+ * the file URL, title, aria label and download file name of each recording,
1893
+ * shared by `ObjectAudioTabBaseComponent` and
1894
+ * `ObjectAudioDescriptionTabBaseComponent`. The fallback label comes from
1895
+ * `audioMediaTabLabelKey`, read lazily, so a subclass overrides it with a
1896
+ * field.
1897
+ *
1898
+ * ```ts
1899
+ * protected override readonly audioMediaTabLabelKey = 'DETAILS_PAGE.tabs.audio';
1900
+ * ```
1901
+ */
1902
+ class ObjectAudioMediaTabBaseComponent extends ObjectMediaTabBaseComponent {
1903
+ constructor() {
1904
+ super(...arguments);
1905
+ /** Translation key of the player label; read lazily, so a subclass can override it. */
1906
+ this.audioMediaTabLabelKey = 'OBJECTS.filters.multimedia.audio';
1907
+ }
1908
+ /** The recording's file URL (`noCache`). */
1909
+ getAudioUrl(audio) {
1910
+ return this.mediaTabFileUrl.get(audio, 'noCache');
1911
+ }
1912
+ /** Alt text in the current language, then the description, then the label key. */
1913
+ getAudioTitle(audio) {
1914
+ const currentLang = this.mediaTabTranslate.currentLang;
1915
+ const altText = currentLang === 'en' ? audio.altTextEn : audio.altText;
1916
+ return (altText ||
1917
+ audio.description ||
1918
+ this.mediaTabTranslate.instant(this.audioMediaTabLabelKey));
1919
+ }
1920
+ /** `<label>: <title>`. */
1921
+ getAudioAriaLabel(audio) {
1922
+ const title = this.getAudioTitle(audio);
1923
+ const audioLabel = this.mediaTabTranslate.instant(this.audioMediaTabLabelKey);
1924
+ return `${audioLabel}: ${title}`;
1925
+ }
1926
+ /** The object title, alt text, description or `audio`, plus the extension (`mp3` by default). */
1927
+ getDownloadFilename(audio) {
1928
+ const name = this.item()?.title || audio.altText || audio.description || 'audio';
1929
+ const ext = audio.extension || 'mp3';
1930
+ return `${name}.${ext}`;
1931
+ }
1932
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAudioMediaTabBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
1933
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectAudioMediaTabBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
1934
+ }
1935
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAudioMediaTabBaseComponent, decorators: [{
1936
+ type: Directive
1937
+ }] });
1938
+
1939
+ /**
1940
+ * Logic-only base of the object audio tab (MOB-1801). Muzeum Podlasia renders
1941
+ * the article player (`app-widget-audio` on `WidgetAudioBaseComponent`,
1942
+ * MOB-1805 / MOB-1872) through `widgetItem` and `trackName`; sztuki-2 and the
1943
+ * template render a native `<audio>` with a download button through
1944
+ * `getAudioUrl`, `getAudioTitle`, `getAudioAriaLabel` and
1945
+ * `getDownloadFilename` (`ObjectAudioMediaTabBaseComponent`, label
1946
+ * `DETAILS_PAGE.tabs.audio`), with `playableOnly: false`. The tab keeps its
1947
+ * template, styles and `host`.
1948
+ *
1949
+ * ```ts
1950
+ * export class AudioTabComponent extends ObjectAudioTabBaseComponent {
1951
+ * constructor() {
1952
+ * super(); // sztuki-2 / template: super({ playableOnly: false })
1953
+ * }
1954
+ * }
1955
+ * ```
1956
+ */
1957
+ class ObjectAudioTabBaseComponent extends ObjectAudioMediaTabBaseComponent {
1958
+ constructor(options = {}) {
1959
+ super();
1960
+ this.audioMediaTabLabelKey = 'DETAILS_PAGE.tabs.audio';
1961
+ this.audioTabOptions = options;
1962
+ }
1963
+ /** With `playableOnly` (default) only recordings with a file. */
1964
+ mediaTabFilterModels(models) {
1965
+ if (this.audioTabOptions.playableOnly === false)
1966
+ return models;
1967
+ // Only recordings with a file — the "Audio 1, 2…" numbering counts what
1968
+ // is shown (MOB-1872 review).
1969
+ return models.filter((audio) => !!audio.filePath);
1970
+ }
1971
+ /**
1972
+ * The recording in the audio widget's shape: the file in `file`, the
1973
+ * heading from the description (without one — a player without a heading,
1974
+ * as in the mock-up).
1975
+ */
1976
+ widgetItem(audio) {
1977
+ return {
1978
+ file: audio,
1979
+ title: audio.description ?? '',
1980
+ };
1981
+ }
1982
+ /**
1983
+ * The recording's name for screen readers (the player's button and
1984
+ * slider): the alt text in the current language or the description;
1985
+ * several recordings without names — "Audio 1", "Audio 2"… (MOB-1872
1986
+ * review); a single one — `null`.
1987
+ */
1988
+ trackName(audio, index) {
1989
+ const altText = this.mediaTabTranslate.currentLang === 'en'
1990
+ ? audio.altTextEn
1991
+ : audio.altText;
1992
+ const name = (altText || audio.description || '').trim();
1993
+ if (name)
1994
+ return name;
1995
+ return this.models().length > 1
1996
+ ? `${this.mediaTabTranslate.instant(this.audioMediaTabLabelKey)} ${index + 1}`
1997
+ : null;
1998
+ }
1999
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAudioTabBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
2000
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectAudioTabBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
2001
+ }
2002
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAudioTabBaseComponent, decorators: [{
2003
+ type: Directive
2004
+ }], ctorParameters: () => [{ type: undefined }] });
2005
+
2006
+ /**
2007
+ * Logic-only base of the object audio description tab (MOB-1801), from Muzeum
2008
+ * Podlasia's `audio-description-tab.component.ts` (sztuki-2 and the template
2009
+ * differ by one template class only): the main image above the players and
2010
+ * the names of each recording (`ObjectAudioMediaTabBaseComponent`, label
2011
+ * `OBJECTS.filters.multimedia.audio`). The tab keeps its template, styles and `host`.
2012
+ *
2013
+ * ```ts
2014
+ * export class AudioDescriptionTabComponent extends ObjectAudioDescriptionTabBaseComponent {}
2015
+ * ```
2016
+ */
2017
+ class ObjectAudioDescriptionTabBaseComponent extends ObjectAudioMediaTabBaseComponent {
2018
+ constructor() {
2019
+ super(...arguments);
2020
+ /** The object's main image (`big` = full resolution without cache); `null` without one or for the no-image placeholder. */
2021
+ this.mainImageUrl = computed(() => {
2022
+ const obj = this.item();
2023
+ if (obj?.image) {
2024
+ const url = this.mediaTabFileUrl.get(obj.image, 'big');
2025
+ if (url && !url.includes('assets/no-image.svg')) {
2026
+ return url;
2027
+ }
2028
+ }
2029
+ return null;
2030
+ }, /* @ts-ignore */
2031
+ ...(ngDevMode ? [{ debugName: "mainImageUrl" }] : /* istanbul ignore next */ []));
2032
+ }
2033
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAudioDescriptionTabBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
2034
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectAudioDescriptionTabBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
2035
+ }
2036
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAudioDescriptionTabBaseComponent, decorators: [{
2037
+ type: Directive
2038
+ }] });
2039
+
2040
+ /**
2041
+ * Logic-only base of the object documents tab (MOB-1801), from Muzeum
2042
+ * Podlasia's `documents-tab.component.ts` (sztuki-2 and the template differ by
2043
+ * one template class only): each document's URL, display name and download
2044
+ * file name. The tab keeps its template, styles and `host`.
2045
+ *
2046
+ * ```ts
2047
+ * export class DocumentsTabComponent extends ObjectDocumentsTabBaseComponent {}
2048
+ * ```
2049
+ */
2050
+ class ObjectDocumentsTabBaseComponent extends ObjectMediaTabBaseComponent {
2051
+ /** The document's file URL (`noCache`). */
2052
+ getDocumentUrl(doc) {
2053
+ return this.mediaTabFileUrl.get(doc, 'noCache');
2054
+ }
2055
+ /** Alt text in the current language, then the description, then the upper-case extension (`PDF` by default). */
2056
+ getDocumentDisplayName(doc) {
2057
+ const currentLang = this.mediaTabTranslate.currentLang;
2058
+ const altText = currentLang === 'en' ? doc.altTextEn : doc.altText;
2059
+ if (altText)
2060
+ return altText;
2061
+ if (doc.description)
2062
+ return doc.description;
2063
+ // Show the extension name when there is no description
2064
+ const extension = doc.extension || 'pdf';
2065
+ return extension.toUpperCase();
2066
+ }
2067
+ /** Alt text in the current language, description or `document`, plus the extension (`pdf` by default). */
2068
+ getDocumentFileName(doc) {
2069
+ const currentLang = this.mediaTabTranslate.currentLang;
2070
+ const altText = currentLang === 'en' ? doc.altTextEn : doc.altText;
2071
+ const name = altText || doc.description || 'document';
2072
+ const extension = doc.extension || 'pdf';
2073
+ return `${name}.${extension}`;
2074
+ }
2075
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectDocumentsTabBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
2076
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectDocumentsTabBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
2077
+ }
2078
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectDocumentsTabBaseComponent, decorators: [{
2079
+ type: Directive
2080
+ }] });
2081
+
2082
+ /**
2083
+ * Creates the selector state of a media tab, 1:1 from Muzeum Podlasia's
2084
+ * animation tab. `fallbackLabel` gets the 0-based index of a model without
2085
+ * `altText` and `description`. When `item()` becomes a different object (a new
2086
+ * `id`), the first model is selected; an object that only re-emits keeps the
2087
+ * user's choice. `item` and `models` are functions (`() => this.item()`), read
2088
+ * lazily, so a tab that redeclares `item` still works. Call it in an injection context (a field initializer),
2089
+ * because it registers an `effect`.
2090
+ */
2091
+ function createMediaTabPicker(item, models, fallbackLabel) {
2092
+ const selectedKey = signal(null, /* @ts-ignore */
2093
+ ...(ngDevMode ? [{ debugName: "selectedKey" }] : /* istanbul ignore next */ []));
2094
+ const options = computed(() => {
2095
+ return models().map((model, index) => ({
2096
+ key: String(model.id),
2097
+ value: model?.altText ?? model?.description ?? fallbackLabel(index),
2098
+ }));
2099
+ }, /* @ts-ignore */
2100
+ ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
2101
+ const selected = computed(() => {
2102
+ const list = models();
2103
+ if (list.length === 0)
2104
+ return null;
2105
+ const key = selectedKey();
2106
+ if (key) {
2107
+ const found = list.find((model) => String(model.id) === key);
2108
+ if (found)
2109
+ return found;
2110
+ }
2111
+ return list[0];
2112
+ }, /* @ts-ignore */
2113
+ ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
2114
+ const selectedOption = computed(() => {
2115
+ const model = selected();
2116
+ const list = options();
2117
+ if (model) {
2118
+ const found = list.find((option) => option.key === String(model.id));
2119
+ if (found)
2120
+ return found;
2121
+ }
2122
+ return list[0] || { key: '', value: '' };
2123
+ }, /* @ts-ignore */
2124
+ ...(ngDevMode ? [{ debugName: "selectedOption" }] : /* istanbul ignore next */ []));
2125
+ let lastItemId = null;
2126
+ effect(() => {
2127
+ const currentItem = item();
2128
+ const list = models();
2129
+ // Only reset the selected key when the item changes (a different object)
2130
+ if (currentItem?.id !== lastItemId) {
2131
+ lastItemId = currentItem?.id ?? null;
2132
+ if (list.length > 0) {
2133
+ selectedKey.set(String(list[0].id));
2134
+ }
2135
+ else {
2136
+ selectedKey.set(null);
2137
+ }
2138
+ }
2139
+ });
2140
+ return { selectedKey, options, selected, selectedOption };
2141
+ }
2142
+
2143
+ /**
2144
+ * Logic-only base of an object tab with one video at a time and a dropdown
2145
+ * (MOB-1801), shared by `ObjectAnimationTabBaseComponent` and
2146
+ * `ObjectVideoTabBaseComponent`, 1:1 from Muzeum Podlasia's animation and
2147
+ * video tabs: the selector state, the player reset on a change and the
2148
+ * player helpers. The translation keys and the download fallback name are
2149
+ * abstract fields, read lazily.
2150
+ */
2151
+ class ObjectVideoMediaTabBaseComponent extends ObjectMediaTabBaseComponent {
2152
+ constructor() {
2153
+ super(...arguments);
2154
+ /** The template's native `<video #videoPlayer>`, if it has one. */
2155
+ this.videoPlayer = viewChild('videoPlayer', /* @ts-ignore */
2156
+ ...(ngDevMode ? [{ debugName: "videoPlayer" }] : /* istanbul ignore next */ []));
2157
+ this.videoMediaTabPicker = createMediaTabPicker(() => this.item(), () => this.models(), (index) => `${this.mediaTabTranslate.instant(this.videoMediaTabOptionLabelKey)} ${index + 1}`);
2158
+ /** The chosen video's key; the first video of each new object. */
2159
+ this.selectedVideoKey = this.videoMediaTabPicker.selectedKey;
2160
+ /** Dropdown options: alt text, description or `<label> <n>`. */
2161
+ this.videoOptions = this.videoMediaTabPicker.options;
2162
+ /** The chosen video, the first one by default. */
2163
+ this.selectedVideo = this.videoMediaTabPicker.selected;
2164
+ /** The dropdown option of `selectedVideo`. */
2165
+ this.selectedVideoOption = this.videoMediaTabPicker.selectedOption;
2166
+ }
2167
+ /** Selects a video; a native `#videoPlayer` is paused and rewound. */
2168
+ videoChange(key) {
2169
+ this.selectedVideoKey.set(key);
2170
+ // Reset video playback
2171
+ const player = this.videoPlayer()?.nativeElement;
2172
+ if (player) {
2173
+ player.pause();
2174
+ player.currentTime = 0;
2175
+ }
2176
+ }
2177
+ /** The video's file URL (`noCache`). */
2178
+ getVideoUrl(video) {
2179
+ return this.mediaTabFileUrl.get(video, 'noCache');
2180
+ }
2181
+ /** Alt text in the current language, then the description, then the title key. */
2182
+ getVideoTitle(video) {
2183
+ const currentLang = this.mediaTabTranslate.currentLang;
2184
+ const altText = currentLang === 'en' ? video.altTextEn : video.altText;
2185
+ return (altText ||
2186
+ video.description ||
2187
+ this.mediaTabTranslate.instant(this.videoMediaTabTitleKey));
2188
+ }
2189
+ /** `<title key>: <title>`. */
2190
+ getVideoAriaLabel(video) {
2191
+ const title = this.getVideoTitle(video);
2192
+ const videoLabel = this.mediaTabTranslate.instant(this.videoMediaTabTitleKey);
2193
+ return `${videoLabel}: ${title}`;
2194
+ }
2195
+ /** The object title, alt text, description or the fallback name, plus the extension (`mp4` by default). */
2196
+ getDownloadFilename(video) {
2197
+ const name = this.item()?.title ||
2198
+ video.altText ||
2199
+ video.description ||
2200
+ this.videoMediaTabDownloadName;
2201
+ const ext = video.extension || 'mp4';
2202
+ return `${name}.${ext}`;
2203
+ }
2204
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectVideoMediaTabBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
2205
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.1.3", type: ObjectVideoMediaTabBaseComponent, isStandalone: true, viewQueries: [{ propertyName: "videoPlayer", first: true, predicate: ["videoPlayer"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0 }); }
2206
+ }
2207
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectVideoMediaTabBaseComponent, decorators: [{
2208
+ type: Directive
2209
+ }], propDecorators: { videoPlayer: [{ type: i0.ViewChild, args: ['videoPlayer', { isSignal: true }] }] } });
2210
+
2211
+ /**
2212
+ * Logic-only base of the object animation tab (MOB-1801), 1:1 from Muzeum
2213
+ * Podlasia (sztuki-2 and the template are identical): options without a name
2214
+ * are `OBJECTS.filters.multimedia.animation <n>`, the player is titled with
2215
+ * `OBJECTS.filters.multimedia.video`, the download falls back to
2216
+ * `animation.mp4`, and `videoChange` pauses and rewinds `#videoPlayer`. The
2217
+ * tab keeps its template, styles and `host`.
2218
+ *
2219
+ * ```ts
2220
+ * export class AnimationTabComponent extends ObjectAnimationTabBaseComponent {}
2221
+ * ```
2222
+ */
2223
+ class ObjectAnimationTabBaseComponent extends ObjectVideoMediaTabBaseComponent {
2224
+ constructor() {
2225
+ super(...arguments);
2226
+ this.videoMediaTabOptionLabelKey = 'OBJECTS.filters.multimedia.animation';
2227
+ this.videoMediaTabTitleKey = 'OBJECTS.filters.multimedia.video';
2228
+ this.videoMediaTabDownloadName = 'animation';
2229
+ }
2230
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAnimationTabBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
2231
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectAnimationTabBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
2232
+ }
2233
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectAnimationTabBaseComponent, decorators: [{
2234
+ type: Directive
2235
+ }] });
2236
+
2237
+ /**
2238
+ * Logic-only base of the object video tab (MOB-1801). Options, title and
2239
+ * aria label use `DETAILS_PAGE.tabs.video`. Muzeum Podlasia renders the app's
2240
+ * video player (a new source resets it — a still frame with a play button,
2241
+ * no playback), so its template has no `#videoPlayer` and `videoChange` only
2242
+ * selects the video. Sztuki-2 and the template render a native
2243
+ * `<video #videoPlayer>`, which `videoChange` pauses and rewinds, and a
2244
+ * download button named by `getDownloadFilename` (fallback `video.mp4`). The
2245
+ * tab keeps its template, styles and `host`.
2246
+ *
2247
+ * ```ts
2248
+ * export class VideoTabComponent extends ObjectVideoTabBaseComponent {}
2249
+ * ```
2250
+ */
2251
+ class ObjectVideoTabBaseComponent extends ObjectVideoMediaTabBaseComponent {
2252
+ constructor() {
2253
+ super(...arguments);
2254
+ this.videoMediaTabOptionLabelKey = 'DETAILS_PAGE.tabs.video';
2255
+ this.videoMediaTabTitleKey = 'DETAILS_PAGE.tabs.video';
2256
+ this.videoMediaTabDownloadName = 'video';
2257
+ }
2258
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectVideoTabBaseComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); }
2259
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: ObjectVideoTabBaseComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); }
2260
+ }
2261
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectVideoTabBaseComponent, decorators: [{
2262
+ type: Directive
2263
+ }] });
2264
+
2265
+ /**
2266
+ * Logic-only base of the object virtual walk tab (MOB-1801), 1:1 from Muzeum
2267
+ * Podlasia (sztuki-2 and the template are identical): the walk dropdown, the
2268
+ * trusted iframe source and the fullscreen toggle of `#walkContainer`. The
2269
+ * `fullscreenchange` listener is registered in the browser only, through the
2270
+ * injected `DOCUMENT`, and removed on destroy. The tab keeps its template,
2271
+ * styles and `host`.
2272
+ *
2273
+ * ```ts
2274
+ * export class VirtualWalkTabComponent extends ObjectVirtualWalkTabBaseComponent {
2275
+ * constructor() {
2276
+ * super();
2277
+ * }
2278
+ * }
2279
+ * ```
2280
+ */
2281
+ class ObjectVirtualWalkTabBaseComponent extends ObjectMediaTabBaseComponent {
2282
+ constructor() {
2283
+ super();
2284
+ this.virtualWalkTabSanitizer = inject(DomSanitizer);
2285
+ this.virtualWalkTabDocument = inject(DOCUMENT);
2286
+ /** The template's `#iframeContainer`. */
2287
+ this.iframeContainer = viewChild('iframeContainer', /* @ts-ignore */
2288
+ ...(ngDevMode ? [{ debugName: "iframeContainer" }] : /* istanbul ignore next */ []));
2289
+ /** The template's `#walkContainer`, the element shown fullscreen. */
2290
+ this.walkContainer = viewChild('walkContainer', /* @ts-ignore */
2291
+ ...(ngDevMode ? [{ debugName: "walkContainer" }] : /* istanbul ignore next */ []));
2292
+ this.virtualWalkTabPicker = createMediaTabPicker(() => this.item(), () => this.models(), (index) => `${this.mediaTabTranslate.instant('OBJECTS.filters.multimedia.virtualWalk')} ${index + 1}`);
2293
+ /** The chosen walk's key; the first walk of each new object. */
2294
+ this.selectedWalkKey = this.virtualWalkTabPicker.selectedKey;
2295
+ /** Dropdown options: alt text, description or `OBJECTS.filters.multimedia.virtualWalk <n>`. */
2296
+ this.walkOptions = this.virtualWalkTabPicker.options;
2297
+ /** The chosen walk, the first one by default. */
2298
+ this.selectedWalk = this.virtualWalkTabPicker.selected;
2299
+ /** The dropdown option of `selectedWalk`. */
2300
+ this.selectedWalkOption = this.virtualWalkTabPicker.selectedOption;
2301
+ /** The selected walk's `iframeSource`, trusted; `null` without one. */
2302
+ this.safeIframeUrl = computed(() => {
2303
+ const walk = this.selectedWalk();
2304
+ if (!walk?.iframeSource)
2305
+ return null;
2306
+ return this.virtualWalkTabSanitizer.bypassSecurityTrustResourceUrl(walk.iframeSource);
2307
+ }, /* @ts-ignore */
2308
+ ...(ngDevMode ? [{ debugName: "safeIframeUrl" }] : /* istanbul ignore next */ []));
2309
+ /** Whether the document is in fullscreen (follows `fullscreenchange`, e.g. Escape). */
2310
+ this.isFullscreen = signal(false, /* @ts-ignore */
2311
+ ...(ngDevMode ? [{ debugName: "isFullscreen" }] : /* istanbul ignore next */ []));
2312
+ if (isPlatformBrowser(inject(PLATFORM_ID))) {
2313
+ // Listen for fullscreen changes (e.g. when the user presses Escape)
2314
+ const doc = this.virtualWalkTabDocument;
2315
+ const onFullscreenChange = () => {
2316
+ this.isFullscreen.set(!!doc.fullscreenElement);
2317
+ };
2318
+ doc.addEventListener('fullscreenchange', onFullscreenChange);
2319
+ inject(DestroyRef).onDestroy(() => {
2320
+ doc.removeEventListener('fullscreenchange', onFullscreenChange);
2321
+ });
2322
+ }
2323
+ }
2324
+ /** Selects a walk. */
2325
+ walkChange(key) {
2326
+ this.selectedWalkKey.set(key);
2327
+ }
2328
+ /** Alt text in the current language, then the description, then `OBJECTS.filters.multimedia.virtualWalk`. */
2329
+ getWalkTitle(walk) {
2330
+ const currentLang = this.mediaTabTranslate.currentLang;
2331
+ const altText = currentLang === 'en' ? walk.altTextEn : walk.altText;
2332
+ return (altText ||
2333
+ walk.description ||
2334
+ this.mediaTabTranslate.instant('OBJECTS.filters.multimedia.virtualWalk'));
2335
+ }
2336
+ /** Shows `#walkContainer` fullscreen, or leaves fullscreen. */
2337
+ toggleFullscreen() {
2338
+ const container = this.walkContainer();
2339
+ if (!container)
2340
+ return;
2341
+ const element = container.nativeElement;
2342
+ const doc = this.virtualWalkTabDocument;
2343
+ if (!doc.fullscreenElement) {
2344
+ element.requestFullscreen().catch((err) => {
2345
+ console.error('Error attempting to enable fullscreen:', err);
2346
+ });
2347
+ }
2348
+ else {
2349
+ doc.exitFullscreen();
2350
+ }
2351
+ }
2352
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectVirtualWalkTabBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2353
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.1.3", type: ObjectVirtualWalkTabBaseComponent, isStandalone: true, viewQueries: [{ propertyName: "iframeContainer", first: true, predicate: ["iframeContainer"], descendants: true, isSignal: true }, { propertyName: "walkContainer", first: true, predicate: ["walkContainer"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0 }); }
2354
+ }
2355
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectVirtualWalkTabBaseComponent, decorators: [{
2356
+ type: Directive
2357
+ }], ctorParameters: () => [], propDecorators: { iframeContainer: [{ type: i0.ViewChild, args: ['iframeContainer', { isSignal: true }] }], walkContainer: [{ type: i0.ViewChild, args: ['walkContainer', { isSignal: true }] }] } });
2358
+
2359
+ /**
2360
+ * Logic-only base of the object 3D model tab (MOB-1801), 1:1 from Muzeum
2361
+ * Podlasia: the model list, the lazily loaded `online-3d-viewer`, the model
2362
+ * switch, the camera reset, the wireframe and the fullscreen toggle. The tab
2363
+ * keeps its template, styles and `host`, and returns its own
2364
+ * `import('online-3d-viewer')` from `loadModel3dLibrary()` — the framework does
2365
+ * not depend on the library, and the viewer is never loaded on the server.
2366
+ * Podlasie's model file URI panel (MOB-981) stays in Podlasie and follows
2367
+ * `selectedModel`.
2368
+ *
2369
+ * ```ts
2370
+ * export class Model3dTabComponent extends ObjectModel3dTabBaseComponent {
2371
+ * constructor() {
2372
+ * super(); // sztuki-2 / template: super({ loadableOnly: false })
2373
+ * }
2374
+ *
2375
+ * protected loadModel3dLibrary() {
2376
+ * return import('online-3d-viewer');
2377
+ * }
2378
+ * }
2379
+ * ```
2380
+ */
2381
+ class ObjectModel3dTabBaseComponent extends ObjectMediaTabBaseComponent {
2382
+ constructor(options = {}) {
2383
+ super();
2384
+ /** The template's `#viewer3d`, the viewer's canvas container. */
2385
+ this.viewer3d = viewChild('viewer3d', /* @ts-ignore */
2386
+ ...(ngDevMode ? [{ debugName: "viewer3d" }] : /* istanbul ignore next */ []));
2387
+ /** The chosen model's index, bound with `ngModel` (may arrive as a string). */
2388
+ this.selectedModelIndex = 0;
2389
+ /** The chosen model's index as a signal, next to `selectedModelIndex` (`onModelChange`). */
2390
+ this.selectedModel = signal(0, /* @ts-ignore */
2391
+ ...(ngDevMode ? [{ debugName: "selectedModel" }] : /* istanbul ignore next */ []));
2392
+ /** A model is loading. */
2393
+ this.isLoading = signal(false, /* @ts-ignore */
2394
+ ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
2395
+ /** The load error, `null` without one. */
2396
+ this.error = signal(null, /* @ts-ignore */
2397
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
2398
+ /** The edges are shown. */
2399
+ this.isWireframe = signal(false, /* @ts-ignore */
2400
+ ...(ngDevMode ? [{ debugName: "isWireframe" }] : /* istanbul ignore next */ []));
2401
+ /** Whether the document is in fullscreen (follows `fullscreenchange`, e.g. Escape). */
2402
+ this.isFullscreen = signal(false, /* @ts-ignore */
2403
+ ...(ngDevMode ? [{ debugName: "isFullscreen" }] : /* istanbul ignore next */ []));
2404
+ this.model3dTabDocument = inject(DOCUMENT);
2405
+ this.model3dTabPlatformId = inject(PLATFORM_ID);
2406
+ this.model3dTabTimeouts = createSafeTimeouts();
2407
+ /**
2408
+ * `online-3d-viewer` (+ `three`, ~1.1 MB) is loaded only on the first
2409
+ * init — a static import put the library in the chunk of the whole objects
2410
+ * module, loaded on the `/obiekty` list and every card, also without a 3D
2411
+ * model (Lighthouse, MOB-1850, MOB-1895). One load is shared between
2412
+ * concurrent calls. Created on first use.
2413
+ */
2414
+ this.model3dTabLibraryLoader = null;
2415
+ /** The number of the latest init — an older call after `await` does not overwrite a newer one. */
2416
+ this.model3dTabInitToken = 0;
2417
+ this.model3dTabViewer = null;
2418
+ this.model3dTabOptions = options;
2419
+ effect(() => {
2420
+ const viewerElement = this.viewer3d();
2421
+ const models = this.models();
2422
+ if (viewerElement && models.length > 0) {
2423
+ this.model3dTabTimeouts.later(() => void this.model3dTabInitViewer(), 100);
2424
+ }
2425
+ });
2426
+ if (isPlatformBrowser(this.model3dTabPlatformId)) {
2427
+ // Listen for fullscreen changes (e.g. when the user presses Escape)
2428
+ const doc = this.model3dTabDocument;
2429
+ const onFullscreenChange = () => {
2430
+ const wasFullscreen = this.isFullscreen();
2431
+ this.isFullscreen.set(!!doc.fullscreenElement);
2432
+ // Reinitialize the viewer when leaving fullscreen to fix sizing issues
2433
+ if (wasFullscreen && !doc.fullscreenElement) {
2434
+ this.model3dTabTimeouts.later(() => void this.model3dTabInitViewer(), 100);
2435
+ }
2436
+ };
2437
+ doc.addEventListener('fullscreenchange', onFullscreenChange);
2438
+ inject(DestroyRef).onDestroy(() => {
2439
+ doc.removeEventListener('fullscreenchange', onFullscreenChange);
2440
+ });
2441
+ }
2442
+ }
2443
+ /** With `loadableOnly` (default) only models with a file to load. */
2444
+ mediaTabFilterModels(models) {
2445
+ if (this.model3dTabOptions.loadableOnly === false)
2446
+ return models;
2447
+ // Only models with a file to load — the same condition as the tab
2448
+ // counter, so the default model does not end in "No files found"
2449
+ // (MOB-1872 review).
2450
+ return models.filter((model) => (model.files ?? []).some((file) => !!file?.filePath));
2451
+ }
2452
+ /** Follows the `selectedModelIndex` dropdown: sets `selectedModel` and loads that model. */
2453
+ onModelChange() {
2454
+ this.selectedModel.set(Number(this.selectedModelIndex) || 0);
2455
+ this.model3dTabLoadModel(this.selectedModelIndex);
2456
+ }
2457
+ /** Fits the model to the window with the Y axis up. A no-op before the library is loaded. */
2458
+ resetCamera() {
2459
+ const library = this.model3dTabLibraryLoader?.loaded;
2460
+ if (this.model3dTabViewer && library) {
2461
+ // Reset the camera to fit the model and reset rotation
2462
+ const viewerInstance = this.model3dTabViewer.GetViewer();
2463
+ // Reset the up vector to default (Y-up)
2464
+ viewerInstance.SetUpVector(library.Direction.Y, false);
2465
+ // Get bounding sphere and fit to window
2466
+ const boundingSphere = viewerInstance.GetBoundingSphere(() => true);
2467
+ viewerInstance.AdjustClippingPlanesToSphere(boundingSphere);
2468
+ viewerInstance.FitSphereToWindow(boundingSphere, true); // Use animation for smooth transition
2469
+ }
2470
+ }
2471
+ /** Shows or hides the edges. A no-op before the library is loaded. */
2472
+ toggleWireframe() {
2473
+ const library = this.model3dTabLibraryLoader?.loaded;
2474
+ if (!this.model3dTabViewer || !library)
2475
+ return;
2476
+ const newWireframeState = !this.isWireframe();
2477
+ this.isWireframe.set(newWireframeState);
2478
+ const edgeSettings = new library.EdgeSettings(newWireframeState, new library.RGBColor(0, 0, 0), 1);
2479
+ const viewerInstance = this.model3dTabViewer.GetViewer();
2480
+ viewerInstance.SetEdgeSettings(edgeSettings);
2481
+ }
2482
+ /** Shows the parent of `#viewer3d` fullscreen, or leaves fullscreen. */
2483
+ toggleFullscreen() {
2484
+ const viewerElement = this.viewer3d();
2485
+ if (!viewerElement)
2486
+ return;
2487
+ const container = viewerElement.nativeElement.parentElement;
2488
+ const doc = this.model3dTabDocument;
2489
+ if (!doc.fullscreenElement) {
2490
+ container.requestFullscreen().catch((err) => {
2491
+ console.error('Error attempting to enable fullscreen:', err);
2492
+ });
2493
+ }
2494
+ else {
2495
+ doc.exitFullscreen();
2496
+ }
2497
+ }
2498
+ model3dTabLibrary() {
2499
+ this.model3dTabLibraryLoader ??= createLazyLoader(() => this.loadModel3dLibrary());
2500
+ return this.model3dTabLibraryLoader;
2501
+ }
2502
+ async model3dTabInitViewer() {
2503
+ if (!isPlatformBrowser(this.model3dTabPlatformId))
2504
+ return;
2505
+ const viewerElement = this.viewer3d();
2506
+ if (!viewerElement)
2507
+ return;
2508
+ const models = this.models();
2509
+ if (models.length === 0)
2510
+ return;
2511
+ const token = ++this.model3dTabInitToken;
2512
+ this.isLoading.set(true);
2513
+ this.error.set(null);
2514
+ let library;
2515
+ try {
2516
+ library = await this.model3dTabLibrary().load();
2517
+ }
2518
+ catch (loadError) {
2519
+ // The library chunk did not arrive (network, old deploy) — a message
2520
+ // instead of an endless loader; the next entry to the tab retries.
2521
+ if (token === this.model3dTabInitToken) {
2522
+ this.isLoading.set(false);
2523
+ this.error.set('3D viewer failed to load');
2524
+ }
2525
+ console.error('online-3d-viewer', loadError);
2526
+ return;
2527
+ }
2528
+ // A newer call came in meanwhile, or the component is gone.
2529
+ if (token !== this.model3dTabInitToken || !this.viewer3d())
2530
+ return;
2531
+ // Clean up existing viewer
2532
+ if (this.model3dTabViewer) {
2533
+ this.model3dTabViewer.Destroy();
2534
+ this.model3dTabViewer = null;
2535
+ }
2536
+ const parentDiv = viewerElement.nativeElement;
2537
+ parentDiv.innerHTML = ''; // Clear previous content
2538
+ // Initialize the viewer with callback for proper loading state
2539
+ this.model3dTabViewer = new library.EmbeddedViewer(parentDiv, {
2540
+ backgroundColor: new library.RGBAColor(245, 245, 245, 255),
2541
+ defaultColor: new library.RGBColor(200, 200, 200),
2542
+ edgeSettings: new library.EdgeSettings(false, new library.RGBColor(0, 0, 0), 1),
2543
+ onModelLoaded: () => {
2544
+ this.isLoading.set(false);
2545
+ },
2546
+ });
2547
+ this.model3dTabLoadModel(this.selectedModelIndex);
2548
+ }
2549
+ model3dTabLoadModel(index) {
2550
+ if (!this.model3dTabViewer)
2551
+ return;
2552
+ const models = this.models();
2553
+ if (index < 0 || index >= models.length)
2554
+ return;
2555
+ const model = models[index];
2556
+ this.isLoading.set(true);
2557
+ this.error.set(null);
2558
+ // Only files with a URL — an empty entry in `files[]` broke the 3D viewer
2559
+ // and blocked the model's valid files (MOB-1872 review).
2560
+ const fileUrls = (model.files ?? [])
2561
+ .map((file) => file?.filePath)
2562
+ .filter((url) => !!url);
2563
+ if (fileUrls.length === 0) {
2564
+ this.error.set('No files found for this model');
2565
+ this.isLoading.set(false);
2566
+ return;
2567
+ }
2568
+ // Load the model - callbacks onModelLoaded/onModelLoadFailed handle loading state
2569
+ this.model3dTabViewer.LoadModelFromUrlList(fileUrls);
2570
+ }
2571
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectModel3dTabBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
2572
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.1.3", type: ObjectModel3dTabBaseComponent, isStandalone: true, viewQueries: [{ propertyName: "viewer3d", first: true, predicate: ["viewer3d"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0 }); }
2573
+ }
2574
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectModel3dTabBaseComponent, decorators: [{
2575
+ type: Directive
2576
+ }], ctorParameters: () => [{ type: undefined }], propDecorators: { viewer3d: [{ type: i0.ViewChild, args: ['viewer3d', { isSignal: true }] }] } });
2577
+
2578
+ /**
2579
+ * Logic-only base of the sztuki-2 / template `media-sections`: an object's
2580
+ * multimedia as landing-page sections (Figma), each with a heading like
2581
+ * „Galeria zdjęć”; a section without data is not rendered. Order: HD photos →
2582
+ * 3D model → animation → video → audio → virtual walk → audio description →
2583
+ * map → other files. The component keeps its template, heading class and
2584
+ * tab imports.
2585
+ *
2586
+ * ```ts
2587
+ * export class MediaSectionsComponent extends ObjectMediaSectionsBaseComponent {
2588
+ * readonly sectionHeadingClass = '…';
2589
+ * }
2590
+ * ```
2591
+ */
2592
+ class ObjectMediaSectionsBaseComponent {
2593
+ constructor() {
2594
+ this.mediaSectionsFileUrl = inject(FileUrlService);
2595
+ this.mediaSectionsMedia = inject(ObjectMediaService);
2596
+ /** The object whose multimedia the sections show. */
2597
+ this.item = input.required(/* @ts-ignore */
2598
+ ...(ngDevMode ? [{ debugName: "item" }] : /* istanbul ignore next */ []));
2599
+ /**
2600
+ * Multimedia already shown in the object card's gallery frame (the video /
2601
+ * 3D tabs, MOB-1961) — not repeated in the sections. Default `[]`.
2602
+ */
2603
+ this.exclude = input([], /* @ts-ignore */
2604
+ ...(ngDevMode ? [{ debugName: "exclude" }] : /* istanbul ignore next */ []));
2605
+ /** `[IMAGES_HD]` */
2606
+ this.imagesHDTypes = [FormFeatureType.IMAGES_HD];
2607
+ /** `[MODEL_3D]` */
2608
+ this.model3DTypes = [FormFeatureType.MODEL_3D];
2609
+ /** `[ANIMATION]` */
2610
+ this.animationTypes = [FormFeatureType.ANIMATION];
2611
+ /** `[AUDIO_DESKRYPCJA]` */
2612
+ this.adTypes = [FormFeatureType.AUDIO_DESKRYPCJA];
2613
+ /** `[VIRTUAL_WALK]` */
2614
+ this.virtualWalkTypes = [FormFeatureType.VIRTUAL_WALK];
2615
+ /** `[VIDEO]` */
2616
+ this.videoTypes = [FormFeatureType.VIDEO];
2617
+ /** `[AUDIO]` */
2618
+ this.audioTypes = [FormFeatureType.AUDIO];
2619
+ /** `[OTHER]` */
2620
+ this.otherTypes = [FormFeatureType.OTHER];
2621
+ /** HD photo thumbnails (without the main image) — fullscreen on click. */
2622
+ this.hdThumbnailUrls = computed(() => this.mediaSectionsMedia.thumbnailUrls(this.item(), this.imagesHDTypes, false), /* @ts-ignore */
2623
+ ...(ngDevMode ? [{ debugName: "hdThumbnailUrls" }] : /* istanbul ignore next */ []));
2624
+ /** HD photo gallery items (without the main image) for the fullscreen. */
2625
+ this.hdGalleryItems = computed(() => this.mediaSectionsMedia.galleryItems(this.item(), this.imagesHDTypes, false), /* @ts-ignore */
2626
+ ...(ngDevMode ? [{ debugName: "hdGalleryItems" }] : /* istanbul ignore next */ []));
2627
+ /** Whether the HD fullscreen gallery is open. */
2628
+ this.hdFullscreenOpen = signal(false, /* @ts-ignore */
2629
+ ...(ngDevMode ? [{ debugName: "hdFullscreenOpen" }] : /* istanbul ignore next */ []));
2630
+ /** The photo the HD fullscreen gallery shows. */
2631
+ this.hdFullscreenIndex = signal(0, /* @ts-ignore */
2632
+ ...(ngDevMode ? [{ debugName: "hdFullscreenIndex" }] : /* istanbul ignore next */ []));
2633
+ /** The object title without HTML tags (alt text). */
2634
+ this.altTitle = computed(() => (this.item()?.title ?? '').replace(/<\/?[^>]+(>|$)/g, ''), /* @ts-ignore */
2635
+ ...(ngDevMode ? [{ debugName: "altTitle" }] : /* istanbul ignore next */ []));
2636
+ /** HD photos section: any HD thumbnail. */
2637
+ this.showPhotosHD = computed(() => this.hdThumbnailUrls().length > 0, /* @ts-ignore */
2638
+ ...(ngDevMode ? [{ debugName: "showPhotosHD" }] : /* istanbul ignore next */ []));
2639
+ /** 3D model section: a real file URL, unless excluded. */
2640
+ this.showModel3D = computed(() => !this.exclude().includes('model3d') &&
2641
+ this.mediaSectionsHasContent(this.model3DTypes), /* @ts-ignore */
2642
+ ...(ngDevMode ? [{ debugName: "showModel3D" }] : /* istanbul ignore next */ []));
2643
+ /** Animation section: a real file URL. */
2644
+ this.showAnimation = computed(() => this.mediaSectionsHasContent(this.animationTypes), /* @ts-ignore */
2645
+ ...(ngDevMode ? [{ debugName: "showAnimation" }] : /* istanbul ignore next */ []));
2646
+ /**
2647
+ * Video section: a file with a `filePath` (`FileUrlService` gives a
2648
+ * placeholder for non-image extensions), unless excluded.
2649
+ */
2650
+ this.showVideo = computed(() => !this.exclude().includes('video') &&
2651
+ this.mediaSectionsMedia.hasFiles(this.item(), this.videoTypes), /* @ts-ignore */
2652
+ ...(ngDevMode ? [{ debugName: "showVideo" }] : /* istanbul ignore next */ []));
2653
+ /** Audio section: a file with a `filePath`. */
2654
+ this.showAudio = computed(() => this.mediaSectionsMedia.hasFiles(this.item(), this.audioTypes), /* @ts-ignore */
2655
+ ...(ngDevMode ? [{ debugName: "showAudio" }] : /* istanbul ignore next */ []));
2656
+ /** Virtual walk section: a walk with an `iframeSource` (not a file URL). */
2657
+ this.showVirtualWalk = computed(() => {
2658
+ const obj = this.item();
2659
+ if (!obj?.additionalImages)
2660
+ return false;
2661
+ const virtualWalkItems = obj.additionalImages.filter((img) => this.virtualWalkTypes.includes(img?.formFeature));
2662
+ // Any virtual walk item with an iframeSource
2663
+ return virtualWalkItems.some((item) => item.iframeSource);
2664
+ }, /* @ts-ignore */
2665
+ ...(ngDevMode ? [{ debugName: "showVirtualWalk" }] : /* istanbul ignore next */ []));
2666
+ /** The number of audio description files with a real URL. */
2667
+ this.audioDescriptionCount = computed(() => {
2668
+ const obj = this.item();
2669
+ if (!obj?.additionalImages)
2670
+ return 0;
2671
+ const audioItems = obj.additionalImages.filter((img) => {
2672
+ if (!this.adTypes.includes(img?.formFeature))
2673
+ return false;
2674
+ const url = this.mediaSectionsFileUrl.get(img);
2675
+ return url && !url.includes('assets/no-image.svg');
2676
+ });
2677
+ return audioItems.length;
2678
+ }, /* @ts-ignore */
2679
+ ...(ngDevMode ? [{ debugName: "audioDescriptionCount" }] : /* istanbul ignore next */ []));
2680
+ /** Audio description section: any audio description. */
2681
+ this.showAudioDescription = computed(() => this.audioDescriptionCount() > 0, /* @ts-ignore */
2682
+ ...(ngDevMode ? [{ debugName: "showAudioDescription" }] : /* istanbul ignore next */ []));
2683
+ /** Map section: both `archeoMap` coordinates set. */
2684
+ this.showMap = computed(() => {
2685
+ const obj = this.item();
2686
+ return !!(obj?.archeoMap?.latitude && obj?.archeoMap?.longitude);
2687
+ }, /* @ts-ignore */
2688
+ ...(ngDevMode ? [{ debugName: "showMap" }] : /* istanbul ignore next */ []));
2689
+ /** Other files section (PDF, etc.): a real file URL. */
2690
+ this.showOther = computed(() => this.mediaSectionsHasContent(this.otherTypes), /* @ts-ignore */
2691
+ ...(ngDevMode ? [{ debugName: "showOther" }] : /* istanbul ignore next */ []));
2692
+ /** Whether any section is visible. */
2693
+ this.hasVisibleSections = computed(() => this.showPhotosHD() ||
2694
+ this.showModel3D() ||
2695
+ this.showAnimation() ||
2696
+ this.showVideo() ||
2697
+ this.showAudio() ||
2698
+ this.showVirtualWalk() ||
2699
+ this.showAudioDescription() ||
2700
+ this.showMap() ||
2701
+ this.showOther(), /* @ts-ignore */
2702
+ ...(ngDevMode ? [{ debugName: "hasVisibleSections" }] : /* istanbul ignore next */ []));
2703
+ }
2704
+ /** Opens the HD fullscreen gallery at the clicked thumbnail. */
2705
+ openHdFullscreen(index) {
2706
+ this.hdFullscreenIndex.set(index);
2707
+ this.hdFullscreenOpen.set(true);
2708
+ }
2709
+ /**
2710
+ * Whether an additional file of the types has a URL from `FileUrlService`
2711
+ * that is not the no-image placeholder. Reads `item()`, so the calling
2712
+ * `computed` tracks it.
2713
+ */
2714
+ mediaSectionsHasContent(types) {
2715
+ const obj = this.item();
2716
+ if (!obj)
2717
+ return false;
2718
+ // Check additional images
2719
+ if (obj.additionalImages && Array.isArray(obj.additionalImages)) {
2720
+ const filtered = obj.additionalImages.filter((img) => types.includes(img?.formFeature));
2721
+ for (const img of filtered) {
2722
+ const url = this.mediaSectionsFileUrl.get(img);
2723
+ if (url && !url.includes('assets/no-image.svg')) {
2724
+ return true;
2725
+ }
2726
+ }
2727
+ }
2728
+ return false;
2729
+ }
2730
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaSectionsBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2731
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.1.3", type: ObjectMediaSectionsBaseComponent, isStandalone: true, inputs: { item: { classPropertyName: "item", publicName: "item", isSignal: true, isRequired: true, transformFunction: null }, exclude: { classPropertyName: "exclude", publicName: "exclude", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
2732
+ }
2733
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ObjectMediaSectionsBaseComponent, decorators: [{
2734
+ type: Directive
2735
+ }], propDecorators: { item: [{ type: i0.Input, args: [{ isSignal: true, alias: "item", required: true }] }], exclude: [{ type: i0.Input, args: [{ isSignal: true, alias: "exclude", required: false }] }] } });
2736
+
1103
2737
  /**
1104
2738
  * Generated bundle index. Do not edit.
1105
2739
  */
1106
2740
 
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 };
2741
+ export { ASK_ABOUT_OBJECT_KEYS, AdvancedFilterType, ComponentsModule, FiltersComponent, FormFeatureType, ListComponent, MULTIMEDIA_DICTIONARY, OBJECTS_CATALOG_DEFAULTS, OBJECTS_CATALOG_SEO, OBJECTS_FILTER_SINGLE_VALUE_LABELS, OBJECTS_LIMIT_OPTIONS, OBJECTS_NEW_TYPE_SORT_OPTIONS, OBJECTS_SHARE_COPIED_TIMEOUT, OBJECTS_SORT_OPTIONS, OBJECTS_THEME_TAGS_FILTER_KEY, ObjectAnimationTabBaseComponent, ObjectAudioDescriptionTabBaseComponent, ObjectAudioMediaTabBaseComponent, ObjectAudioTabBaseComponent, ObjectDetailsBaseComponent, ObjectDocumentsTabBaseComponent, ObjectDownloadButtonBaseComponent, ObjectMapTabBaseComponent, ObjectMediaSectionsBaseComponent, ObjectMediaService, ObjectMediaTabBaseComponent, ObjectModel3dTabBaseComponent, ObjectVideoMediaTabBaseComponent, ObjectVideoTabBaseComponent, ObjectVirtualWalkTabBaseComponent, ObjectsAdvancedFiltersBaseComponent, ObjectsCatalogBaseComponent, ObjectsDataService, ObjectsFacade, ObjectsFiltersBarBaseComponent, ObjectsItemComponent, ObjectsListComponent, ObjectsModule, ObjectsSearchPanelBaseComponent, ObjectsService, ObjectsShareResultsBaseComponent, ObjectsSharedModule, SERVICES, askAboutObjectHref, bibliography, buildObjectsResultsUrl, descriptionHtml, getObjectsState, getReducer, initialState, inventoryValues, objectTitle, objectsSortFromKey, objectsSortKey, relatedArticles, tagValues };
1108
2742
  //# sourceMappingURL=smartsoft001-mobilems-objects-shell-angular.mjs.map