@museumwnf/viewer-core 1.15.1 → 1.16.1

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.
@@ -0,0 +1,62 @@
1
+ // The partner list and profile, as specs. The grouping, the A–Z toggle, the
2
+ // tab strip's mechanics, the language switch and the media gallery are the
3
+ // platform's (`PartnerListView`/`RecordView`); what stays here is an
4
+ // exhibition's own — the E6 hidden-partner rule (the view reads the raw
5
+ // `partners` entity directly, not `visiblePartners`, so it never sees that
6
+ // filter unless told), the country label, and which route a museum or an
7
+ // institution goes to. This exhibition-shaped `partnerSpecs.js` never
8
+ // carried an objects-page spec (no `PartnerObjects.vue` counterpart built
9
+ // on it), unlike the gallery family's `partner.js`.
10
+
11
+ /**
12
+ * The exhibition's partner specs, over its data layer's `data`.
13
+ *
14
+ * Reads `data.defaultLang`, `data.isHiddenPartner`, `data.labelOf`,
15
+ * `data.partnerRoute`. `config` carries nothing today.
16
+ */
17
+ export function useExhibitionPartner(data, config = {}) {
18
+ void config
19
+ const { defaultLang, isHiddenPartner, labelOf, partnerRoute } = data
20
+
21
+ // ── The list (Partners.vue) ─────────────────────────────────────────
22
+ //
23
+ // The DXA shape: no tier column at all — legacy's museum/institution
24
+ // split is a route, not a group here — an A–Z / Z–A toggle over the
25
+ // country groups, and every partner gets a row whether or not it holds
26
+ // anything (legacy's MWNF-384 branch). `variant: 'open'` keeps every
27
+ // group expanded, as legacy's page did with no accordion at all.
28
+ const partnerListSpec = {
29
+ scope: (partner) => !isHiddenPartner(partner),
30
+ group: { tier: false, order: 'country' },
31
+ variant: 'open',
32
+ orderToggle: true,
33
+ label: (countryId) => labelOf('countries', countryId),
34
+ route: (partner) => partnerRoute(partner),
35
+ }
36
+
37
+ // ── The profile (PartnerProfile.vue) ─────────────────────────────────
38
+ //
39
+ // No `fields`: legacy's Description/Contact/Logo tabs are three different
40
+ // shapes (Markdown, an address block, a bare image), not three rows of
41
+ // one sheet, so the panel is built in the view's slots over
42
+ // `text`/`record` directly rather than forced through `sheetRows`. No
43
+ // `citation`/`related` either — legacy's partner page has neither.
44
+ const partnerSheetSpec = {
45
+ entity: 'partners',
46
+ route: (partner) => partnerRoute(partner),
47
+ citation: false,
48
+ related: false,
49
+ media: (record, { language }) =>
50
+ [...(record.images ?? [])]
51
+ .sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
52
+ .map((p) => ({
53
+ url: p.url,
54
+ alt: labelOf('partners', record.id),
55
+ caption: p.captions?.[language] ?? p.captions?.[defaultLang] ?? '',
56
+ photographer: p.photographer ?? '',
57
+ copyright: p.copyright ?? '',
58
+ })),
59
+ }
60
+
61
+ return { partnerListSpec, partnerSheetSpec }
62
+ }
@@ -0,0 +1,126 @@
1
+ // The item sheet, as a spec: what viewer-layout's `RecordView` renders on
2
+ // `/item/:id`. The mechanics — which language the record is read in, what
3
+ // is loaded for it, the glossary terms it reaches, how a field becomes a
4
+ // row, the credits, the citation, the related records — are the platform's.
5
+ // What is declared here is only what is an exhibition's: the field
6
+ // specification (legacy DatabaseItem.vue's `objectData`, field for field),
7
+ // the photos in their order, the related tile, and the citation whose
8
+ // project name follows an exhibition's own rule. Every label is an entry
9
+ // name, written out so the check that every name resolves can read it.
10
+ //
11
+ // Order and labels are the legacy sheet's. Empty values are dropped by the
12
+ // engine, as legacy's `filterData` did. `notice` and `notice_c` were never
13
+ // imported; `notice_b` is the image rights statement, imported as
14
+ // `extra.copyright` and rendered as the last row, where legacy put its own
15
+ // block. The labels are in the language the visitor reads the website in —
16
+ // where it differs from the record's (a deep link into a borrowed record in
17
+ // a language an exhibition does not offer) they fall back to English,
18
+ // which is where legacy pinned them anyway.
19
+ //
20
+ // Legacy shows both descriptions when both exist, with the short one
21
+ // collapsed behind a toggle (the view's, folded after the description);
22
+ // when only one exists it is relabelled plain "Description". EPM records
23
+ // are the common case of the latter.
24
+
25
+ /**
26
+ * The exhibition's item-sheet spec, over its data layer's `data`.
27
+ *
28
+ * Reads `data.defaultLang`, `data.itemRoute`, `data.labelOf`,
29
+ * `data.mdInline`, `data.mdStrip`, `data.partnerById`, `data.projectName`,
30
+ * `data.tr`, `data.translations`. `config` carries nothing today.
31
+ */
32
+ export function useExhibitionSheet(data, config = {}) {
33
+ void config
34
+ const { defaultLang, itemRoute, labelOf, mdInline, mdStrip, partnerById, projectName, tr, translations } = data
35
+
36
+ const bothDescriptions = (c) => Boolean(c.text.description) && Boolean(c.text.short_description)
37
+
38
+ const dynastyNames = (c) =>
39
+ (c.record.dynasty_ids ?? [])
40
+ .map((id) => translations('dynasties', c.language)[id]?.name ?? translations('dynasties', defaultLang)[id]?.name ?? '')
41
+ .filter(Boolean)
42
+ .join(', ')
43
+
44
+ const fields = [
45
+ { key: 'name', label: 'sheet.field.name', value: 'name' },
46
+ { key: 'aka', label: 'sheet.field.alsoKnownAs', value: 'alternate_name' },
47
+ { key: 'location', label: 'sheet.field.location', value: (c) => [c.text.location, labelOf('countries', c.record.country_id)].filter(Boolean).join(', ') },
48
+ // A link to the partner's page, rendered by the wrapper's `museum` slot.
49
+ { key: 'museum', label: 'sheet.field.holdingMuseum', value: (c) => (partnerById.value.get(c.record.partner_id) ? c.record.partner_id : ''), render: 'custom' },
50
+ { key: 'originalOwner', label: 'sheet.field.originalOwner', value: 'initial_owner' },
51
+ { key: 'currentOwner', label: 'sheet.field.currentOwner', value: 'owner' },
52
+ { key: 'date', label: 'sheet.field.date', value: 'dates' },
53
+ { key: 'artist', label: 'sheet.field.artists', value: (c) => c.record.artist_names, join: ', ' },
54
+ { key: 'scribe', label: 'sheet.field.scribe', value: 'scriber' },
55
+ { key: 'workshop', label: 'sheet.field.workshop', value: 'workshop' },
56
+ { key: 'type', label: 'sheet.field.type', value: 'type' },
57
+ { key: 'inventoryNumber', label: 'sheet.field.inventoryNumber', value: (c) => c.record.owner_reference },
58
+ { key: 'materials', label: 'sheet.field.materials', value: (c) => c.text.materials, join: '; ' },
59
+ { key: 'dimensions', label: 'sheet.field.dimensions', value: 'dimensions' },
60
+ { key: 'dynasty', label: 'sheet.field.periodDynasty', value: dynastyNames },
61
+ { key: 'production', label: 'sheet.field.placeOfProduction', value: 'place_of_production' },
62
+ { key: 'provenance', label: 'sheet.field.provenance', value: 'provenance' },
63
+ { key: 'binding', label: 'sheet.field.binding', value: 'binding_desc' },
64
+ { key: 'description', label: 'sheet.field.description', value: 'description', render: 'block' },
65
+ // The only description there is, under the plain label; the toggled one
66
+ // is the view's, folded after `description` when both exist.
67
+ { key: 'shortDescription', label: 'sheet.field.description', value: 'short_description', render: 'block', when: (c) => !bothDescriptions(c) },
68
+ { key: 'catalogue', label: 'sheet.field.catalogueLink', value: 'linkcatalogs', render: 'link' },
69
+ { key: 'obtention', label: 'sheet.field.obtentionMethod', value: 'obtention' },
70
+ { key: 'datation', label: 'sheet.field.datationMethod', value: 'method_for_datation' },
71
+ { key: 'provenanceMethod', label: 'sheet.field.provenanceMethod', value: 'method_for_provenance' },
72
+ { key: 'bibliography', label: 'sheet.field.bibliography', value: 'bibliography', render: 'block' },
73
+ { key: 'copyright', label: 'sheet.field.copyrightInformation', value: 'copyright' },
74
+ ]
75
+
76
+ const itemSheet = {
77
+ entity: 'items',
78
+ translations: ['glossary', 'dynasties', 'partners'],
79
+ // Attribution names are language-independent, but the importer files
80
+ // `author` / `copy_editor` for EPM records on the Arabic row only (a
81
+ // known gap, recorded in the exporter's README). Legacy printed them on
82
+ // every sheet, so the platform reads them off another row when the
83
+ // active one has neither — the one place a page reads across
84
+ // languages, and only for proper names.
85
+ attribution: ['author', 'copy_editor'],
86
+ fields,
87
+ layout: 'list',
88
+ shortDescription: 'short_description',
89
+ shortDescriptionAfter: 'description',
90
+ media: (record, { language }) =>
91
+ [...(record.images ?? [])]
92
+ .sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
93
+ .map((p) => ({
94
+ url: p.url,
95
+ alt: labelOf('items', record.id),
96
+ caption: p.captions?.[language] ?? p.captions?.[defaultLang] ?? '',
97
+ photographer: p.photographer ?? '',
98
+ copyright: p.copyright ?? '',
99
+ })),
100
+ citation: {
101
+ permalink: false,
102
+ heading: 'record.citation.ofThisPage',
103
+ // The manifest's own project name for this record, read in the
104
+ // record's own display language, not the site's active one — a
105
+ // record legacy left nameless (the Explore monuments database) still
106
+ // cites nothing.
107
+ project: (record, { language }) => projectName(record, language),
108
+ },
109
+ related: {
110
+ variant: 'grid',
111
+ heading: 'exhibition.related.objects',
112
+ actionLabel: 'exhibition.action.seeDatabaseEntry',
113
+ record: ({ record: other, justification }) => ({
114
+ id: other.id,
115
+ image: other.images?.[0]?.url ?? '',
116
+ imageAlt: labelOf('items', other.id),
117
+ name: mdInline(tr('items', other.id, defaultLang).name ?? other.internal_name ?? ''),
118
+ meta: [labelOf('countries', other.country_id), justification ? mdStrip(justification) : ''].filter(Boolean),
119
+ to: itemRoute(other),
120
+ }),
121
+ },
122
+ route: 'item',
123
+ }
124
+
125
+ return { itemSheet }
126
+ }
@@ -0,0 +1,310 @@
1
+ import { computed } from 'vue'
2
+ import { eraLabel, roundOutward, yearBucketsFromRange } from '../../catalogue/dates.js'
3
+ import { PAGE_SIZE } from '../shared.js'
4
+
5
+ export { eraLabel, roundOutward }
6
+
7
+ /**
8
+ * The exhibition's timeline spec, over its data layer's `data` and the
9
+ * `PAGE_SIZE`/`tile` its {@link import('./useExhibitionCollection.js').useExhibitionCollection}
10
+ * already declared.
11
+ *
12
+ * Reads `data.exhibition`, `data.timelines`, `data.timelineEvents`,
13
+ * `data.countries`, `data.countryById`, `data.items`, `data.labelOf`,
14
+ * `data.tr`, `data.defaultLang`. `config` carries nothing today.
15
+ */
16
+ export function useExhibitionTimeline(data, collection, config = {}) {
17
+ void config
18
+ const { exhibition, timelines, timelineEvents, countries, countryById, items, labelOf, tr, defaultLang } = data
19
+ const { tile } = collection
20
+
21
+ // ── Which chronology this site's Timeline section shows ─────────────────
22
+ //
23
+ // An exhibition package carries BOTH chronologies, because the live
24
+ // instance serves both: `/events` answers the worldwide country merge on
25
+ // every DXA site, and `/thg/timeline` answers the exhibition's own
26
+ // narrative one. What decides which the *Timeline pages* show is
27
+ // `hasCountryBasedTimeline`: legacy's TimelinePage sets
28
+ // `countriesAvailable` from it and TimelineResults switches its endpoint
29
+ // on it (`timelineURL = "/thg/timeline?hash="` in the false branch).
30
+ //
31
+ // Both flags may be false for an exhibition, and that does NOT mean "no
32
+ // timeline data". `timelines.json` still ships the worldwide merge in
33
+ // full — every DXA site gets the worldwide merge whatever its flags say —
34
+ // possibly with no `thg_local` row at all. The flags gate NAVIGATION, not
35
+ // data.
36
+ //
37
+ // The `thg_local` row has no `country_id`, which is exactly why it must
38
+ // be separated by `source` rather than left to the country filter: on
39
+ // "All Countries" it would otherwise be interleaved into the worldwide
40
+ // list.
41
+ const localTimeline = computed(
42
+ () => timelines.value.find((t) => t.source === 'thg_local') ?? null,
43
+ )
44
+
45
+ /** True when the Timeline section is the exhibition's own chronology. */
46
+ const usesLocalTimeline = computed(
47
+ () => !exhibition.value?.has_country_timeline && Boolean(localTimeline.value),
48
+ )
49
+
50
+ /**
51
+ * Whether this site offers a Timeline at all — the one flag every piece
52
+ * of timeline chrome is gated on.
53
+ *
54
+ * Legacy hides more than the nav entry when both chronology flags are
55
+ * false, which is only visible on a site that has them both false. The
56
+ * ROUTES stay reachable, because legacy's do: typing /timeline on a live
57
+ * instance with both flags false still renders the page and its
58
+ * introduction. Only the links into it are withheld.
59
+ */
60
+ const hasTimeline = computed(
61
+ () => Boolean(exhibition.value?.has_timeline || exhibition.value?.has_country_timeline),
62
+ )
63
+
64
+ /** The events this site's manual Timeline reads (ItemSheet's own popout). */
65
+ const eventPool = computed(() => {
66
+ const local = localTimeline.value
67
+ if (usesLocalTimeline.value) {
68
+ return timelineEvents.value.filter((e) => e.timeline_id === local.id)
69
+ }
70
+ if (!local) return timelineEvents.value
71
+ return timelineEvents.value.filter((e) => e.timeline_id !== local.id)
72
+ })
73
+
74
+ // The global country timeline, served by legacy `/v2/events`. It is
75
+ // country-scoped and project-independent, and also a MERGE of two
76
+ // chronologies rather than one table: legacy's `App\MWNF\DAO\v2\Events`
77
+ // unions `mwnf3.hcr` (the Discover Islamic Art country chronologies) with
78
+ // `mwnf3_sharing_history.sh_hcr` restricted to exhibition 2, "Political
79
+ // Context", and sorts the result by year. The package mirrors that: each
80
+ // row of `timelines.json` is tagged `source: 'mwnf3' | 'sharing_history'`,
81
+ // and `timeline_events.json` keys every event by `country_id`.
82
+ //
83
+ // A country can be served by BOTH sources, so anything user-facing must
84
+ // key on the country and never on the timeline row: `findEvents` filters
85
+ // on `country_id`, which is what merges the two chronologies into one
86
+ // year-ordered list the way legacy did.
87
+ //
88
+ // Names and legacy codes both come from countries.json. The exporter
89
+ // scopes that file to "member item countries ∪ their holders' countries ∪
90
+ // the global timeline's countries", so it covers every timeline country —
91
+ // the `Intl.DisplayNames` fallback below is therefore dead code for them,
92
+ // and is kept only so a regressed package degrades to a rendered ISO code
93
+ // rather than a raw id. The `code` countries.json ships is the country's
94
+ // `backward_compatibility`, which is exactly the code legacy's own
95
+ // timeline URLs used, including the ones that are not ISO 3166-1
96
+ // alpha-2 (`uk`, `pa`, `qt`, `rm`, `sb`, `ua`, …). Those are the reason
97
+ // the fallback must stay unreachable rather than merely rare: read as
98
+ // ISO, `ua` is Ukraine and `sb` is the Solomon Islands, where legacy
99
+ // means the UAE and Serbia. Only countries.json can name them correctly.
100
+ const regionNames = (() => {
101
+ try {
102
+ return new Intl.DisplayNames(['en'], { type: 'region' })
103
+ } catch {
104
+ return null
105
+ }
106
+ })()
107
+
108
+ // Two legacy codes are not ISO 3166-1 alpha-2.
109
+ const LEGACY_TO_ISO = { uk: 'GB', pa: 'PS' }
110
+
111
+ // The set of inventory country ids present in countries.json. Ids and
112
+ // legacy codes are distinct — legacy codes are backward_compatibility
113
+ // values, while ids are the canonical keys in the inventory system.
114
+ const countryIdSet = computed(
115
+ () => new Set(countries.value.map((c) => c.id)),
116
+ )
117
+
118
+ // A lookup, not a parse. The fallback exists only for the
119
+ // regressed-package case described above, and it must agree with
120
+ // GLOBAL_TIMELINE_LIKE_PATTERNS in the exporter's timeline exporter: the
121
+ // country sits after the literal `country` segment in BOTH keyspaces
122
+ // (`mwnf3:hcr:country:<cc>` and
123
+ // `mwnf3_sharing_history:sh_hcr:country:<cc>:exhibition:2`), and it is
124
+ // the last segment in only the first — taking the last one yields `2` on
125
+ // the second.
126
+ function legacyCodeOf(timeline) {
127
+ const fromPackage = countryById.value.get(timeline.country_id)?.code
128
+ if (fromPackage) return fromPackage
129
+ const parts = (timeline.backward_compatibility ?? '').split(':')
130
+ const at = parts.indexOf('country')
131
+ return (at >= 0 ? parts[at + 1] : null) || timeline.country_id
132
+ }
133
+
134
+ function nameFor(timeline) {
135
+ const fromPackage = countries.value.some((c) => c.id === timeline.country_id)
136
+ ? labelOf('countries', timeline.country_id)
137
+ : null
138
+ if (fromPackage) return fromPackage
139
+ const legacy = legacyCodeOf(timeline)
140
+ const iso = LEGACY_TO_ISO[legacy] ?? String(legacy).toUpperCase()
141
+ try {
142
+ return regionNames?.of(iso) ?? iso
143
+ } catch {
144
+ return iso
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Countries that actually have a chronology, alphabetized, "All" first.
150
+ *
151
+ * One entry per COUNTRY, not per timeline row: a country served by BOTH
152
+ * `mwnf3` and `sharing_history` has two rows, and would otherwise appear
153
+ * twice in every country picker on the site. Only the item sheet's own
154
+ * manual "Timeline for this item" picker reads this now — `timelineSpec`
155
+ * below leaves the picker to viewer-core's `useTimelineEvents`.
156
+ */
157
+ const timelineCountries = computed(() => {
158
+ // No picker at all when the section is the exhibition's own chronology
159
+ // — legacy hides the select on `countriesAvailable === false`.
160
+ if (usesLocalTimeline.value) return []
161
+ const byCountry = new Map()
162
+ for (const timeline of timelines.value) {
163
+ if (!timeline.country_id || byCountry.has(timeline.country_id)) continue
164
+ byCountry.set(timeline.country_id, [legacyCodeOf(timeline), nameFor(timeline)])
165
+ }
166
+ const rows = [...byCountry.values()].sort((a, b) => a[1].localeCompare(b[1]))
167
+ return [['all', 'All Countries'], ...rows]
168
+ })
169
+
170
+ /** Display name for an event's country — also `timelineSpec`'s `countryLabel`. */
171
+ function timelineCountryName(countryId) {
172
+ if (countries.value.some((c) => c.id === countryId)) return labelOf('countries', countryId)
173
+ const timeline = timelines.value.find((t) => t.country_id === countryId)
174
+ return timeline ? nameFor(timeline) : countryId
175
+ }
176
+
177
+ /**
178
+ * Legacy 2-letter code → the inventory country id the events are keyed
179
+ * by. A country served by both chronologies has two rows carrying the
180
+ * same code and the same `country_id`, so either row answers. Idempotent
181
+ * on an id already: `TimelineResultsView`'s own country picker offers
182
+ * ids, so this must resolve BOTH the legacy code a bookmarked or
183
+ * cross-page link carries and the id the picker itself round-trips.
184
+ */
185
+ function countryIdForCode(code) {
186
+ if (!code || code === 'all') return null
187
+ if (countryIdSet.value.has(code)) return code
188
+ if (countryById.value.has(code)) return code
189
+ const timeline = timelines.value.find((t) => legacyCodeOf(t) === code)
190
+ if (timeline) return timeline.country_id
191
+ // Countries with no chronology still reach here from the collection
192
+ // page's "Timeline for this Search" link, which uses countries.json's
193
+ // own codes.
194
+ return countries.value.find((c) => c.code === code)?.id ?? null
195
+ }
196
+
197
+ const eventsTr = (id) => tr('timeline_events', id, defaultLang)
198
+
199
+ /**
200
+ * Legacy's `/events?ic[]=&ya=&yo=` — events for a country within a year
201
+ * range, ordered chronologically. Kept for the item sheet's own "Timeline
202
+ * for this item" popout, which reads a single item's country and dates
203
+ * rather than a URL's filters and has no results page of its own to hand
204
+ * to `TimelineResultsView`.
205
+ */
206
+ function findEvents({ countryCode, start, end }) {
207
+ const countryId = countryIdForCode(countryCode)
208
+ const from = start === '' || start == null ? null : Number(start)
209
+ const to = end === '' || end == null ? null : Number(end)
210
+
211
+ return eventPool.value
212
+ .filter((e) => {
213
+ if (countryId && e.country_id !== countryId) return false
214
+ const year = e.year_from
215
+ if (!Number.isFinite(year)) return false
216
+ if (from != null && year < from) return false
217
+ if (to != null && year > to) return false
218
+ return true
219
+ })
220
+ .map((e) => ({
221
+ ...e,
222
+ countryName: timelineCountryName(e.country_id),
223
+ text: eventsTr(e.id),
224
+ }))
225
+ .sort((a, b) => (a.year_from - b.year_from) || (a.display_order ?? 0) - (b.display_order ?? 0))
226
+ }
227
+
228
+ // ── The Timeline entrance/results spec ───────────────────────────────
229
+ //
230
+ // Feeds `TimelineResultsView`: the engine (viewer-core's
231
+ // `useTimelineEvents`) owns the country/local axis, the year buckets and
232
+ // the pagination; an exhibition supplies only what is its own — the
233
+ // legacy country-code table, the local/country scope switch and the
234
+ // controls it drives (no country picker when the section is the
235
+ // exhibition's own chronology), and the "See Gallery" join against
236
+ // member items, which stays here because an item's own shape (what
237
+ // counts as "in this period") is the site's, not the package's.
238
+
239
+ function yearOptions({ years, t }) {
240
+ return yearBucketsFromRange(years.min, years.max, t)
241
+ }
242
+
243
+ /** The member items overlapping a country and period, for the gallery link. */
244
+ function galleryItems({ filters }) {
245
+ const countryId = countryIdForCode(filters.country)
246
+ const from = filters.begin ? Number(filters.begin) : null
247
+ const to = filters.end ? Number(filters.end) : null
248
+ return items.value.filter((i) => {
249
+ if (countryId && i.country_id !== countryId) return false
250
+ const itemStart = i.start_date
251
+ const itemEnd = i.end_date ?? i.start_date
252
+ if (!Number.isFinite(itemStart)) return false
253
+ if (from != null && itemEnd < from) return false
254
+ if (to != null && itemStart > to) return false
255
+ return true
256
+ }).length
257
+ }
258
+
259
+ const timelineSpec = computed(() => ({
260
+ scope: usesLocalTimeline.value ? 'local' : 'country',
261
+ countryLabel: timelineCountryName,
262
+ countryIdForCode,
263
+ tr: eventsTr,
264
+ controls: usesLocalTimeline.value
265
+ ? [{ key: 'begin', options: yearOptions }, { key: 'end', options: yearOptions }]
266
+ : [{ key: 'country' }, { key: 'begin', options: yearOptions }, { key: 'end', options: yearOptions }],
267
+ route: 'timeline-results',
268
+ gallery: { route: 'timeline-gallery', items: galleryItems },
269
+ }))
270
+
271
+ // ── The gallery spec ────────────────────────────────────────────────
272
+ //
273
+ // `CatalogueResultsView` over the same country/period join `galleryItems`
274
+ // counts for the "See Gallery" link above — the query keys are the
275
+ // results spec's own `begin`/`end` (viewer-layout's `TimelineResultsView`
276
+ // renders those controls by that name, not `start`/`end`), so the legacy
277
+ // `/timeline-gallery/:country/:start/:end/:page` redirect is renamed to
278
+ // match on the way in (the site's own `dataset.config.js`).
279
+ const timelineGallerySpec = {
280
+ entity: 'items',
281
+ keys: ['country', 'begin', 'end'],
282
+ scope: (item, filters) => {
283
+ if (!Number.isFinite(item.start_date)) return false
284
+ const countryId = countryIdForCode(filters.country)
285
+ return !countryId || item.country_id === countryId
286
+ },
287
+ dates: { mode: 'overlap', begin: 'begin', end: 'end' },
288
+ sort: { undated: 'first' },
289
+ pageSize: PAGE_SIZE,
290
+ variant: 'grid',
291
+ recordRoute: 'item',
292
+ actionLabel: 'exhibition.action.seeDatabaseEntry',
293
+ empty: 'exhibition.results.noObjectsInPeriod',
294
+ record: (item, { t }) => tile(item, t),
295
+ summary: ({ filters, pageInfo, t }) => [
296
+ {
297
+ label: t('timeline.results.galleryHeading'),
298
+ value: filters.country
299
+ ? timelineCountryName(countryIdForCode(filters.country))
300
+ : t('timeline.form.allCountries'),
301
+ },
302
+ { count: pageInfo.total, value: t('catalogue.results.objects') },
303
+ ],
304
+ }
305
+
306
+ return {
307
+ usesLocalTimeline, hasTimeline, timelineCountries, timelineCountryName, countryIdForCode,
308
+ findEvents, timelineSpec, timelineGallerySpec,
309
+ }
310
+ }
@@ -0,0 +1,175 @@
1
+ import { projectLabel } from '../../conventions.js'
2
+ import { DATE_MODE, FACET_CATEGORIES, PAGE_SIZE } from '../shared.js'
3
+
4
+ export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, PAGE_SIZE, useFacetLabels } from '../shared.js'
5
+
6
+ // The catalogue spec: what a DXA gallery's lists filter and search on. The
7
+ // engine — query state, options, dates, pages, the boolean grammar — is
8
+ // viewer-core's own (`useFacets`, `useListQuery`, …); what is declared here
9
+ // is only what is a gallery's: the facet *predicates* over its own data,
10
+ // the haystack the search bar reads, and the tile a record becomes.
11
+ // `PAGE_SIZE`/`DATE_MODE`/`FACET_CATEGORIES`/`FACET_LABEL_KEYS`/
12
+ // `useFacetLabels` are shared verbatim with the exhibition family — see
13
+ // `../shared.js`.
14
+ //
15
+ // Legacy asked the API for everything: `/items?ic[]=…&id[]=…&na=…&nz=…` for
16
+ // the results, and `/items/countries`, `/items/tags`, `/items/years` *with
17
+ // the same filters applied* for the dropdowns — which is what made the
18
+ // facets dependent (pick a country, and the type list shrinks to the types
19
+ // still reachable). The results page hands the engine the matching subset
20
+ // for that reason; the entrance hands it everything.
21
+
22
+ /**
23
+ * The gallery's collection spec, over its own data layer's `data` (the
24
+ * return value of {@link import('./useGalleryData.js').useGalleryData}).
25
+ *
26
+ * Reads `data.countries`, `data.tags`, `data.countryById`, `data.tagById`,
27
+ * `data.labelOf`, `data.itemRoute`, `data.tr`, `data.defaultLang`,
28
+ * `data.mdInline` and `data.manifest`. `config` carries nothing today.
29
+ */
30
+ export function useGalleryCollection(data, config = {}) {
31
+ void config
32
+ const { countries, tags, countryById, tagById, labelOf, itemRoute, tr, defaultLang, mdInline, manifest } = data
33
+
34
+ // ── What the URL carries ─────────────────────────────────────────────
35
+ //
36
+ // The legacy 2-letter country code for `country`, the legacy tag id for a
37
+ // facet: the values legacy's URLs carried, so a link shared then still
38
+ // resolves now.
39
+
40
+ function countryIdForCode(code) {
41
+ return code ? (countries.value ?? []).find((c) => c.code === code)?.id ?? null : null
42
+ }
43
+
44
+ function tagIdForLegacy(legacyId) {
45
+ return legacyId ? (tags.value ?? []).find((t) => t.legacy_tag_id === legacyId)?.id ?? null : null
46
+ }
47
+
48
+ function tagLabelForLegacy(legacyId) {
49
+ return (tags.value ?? []).find((t) => t.legacy_tag_id === legacyId)?.label ?? legacyId
50
+ }
51
+
52
+ /**
53
+ * The facet spec for viewer-core's `useFacets`: the country by code, each
54
+ * tag category by legacy id, labels upper-cased on the first letter as
55
+ * legacy printed them.
56
+ */
57
+ const FACETS = {
58
+ country: {
59
+ values: (item) => countryById.value.get(item.country_id)?.code ?? null,
60
+ label: (code) => labelOf('countries', countryIdForCode(code)),
61
+ },
62
+ ...Object.fromEntries(
63
+ FACET_CATEGORIES.map((category) => [
64
+ category,
65
+ {
66
+ values: (item) =>
67
+ (item.tag_ids ?? [])
68
+ .map((id) => tagById.value.get(id))
69
+ .filter((tag) => tag && tag.category === category)
70
+ .map((tag) => tag.legacy_tag_id),
71
+ label: tagLabelForLegacy,
72
+ capitalize: true,
73
+ },
74
+ ]),
75
+ ),
76
+ }
77
+
78
+ /** The legacy `/items` predicate's tag half: every requested tag, ANDed. */
79
+ function hasEveryTag(item, tagIds) {
80
+ return tagIds.every((id) => item.tag_ids?.includes(id))
81
+ }
82
+
83
+ // ── The search bar ───────────────────────────────────────────────────
84
+ //
85
+ // Legacy ran MySQL boolean full-text search over the English sheet; the
86
+ // haystack is the same set of fields, and the grammar is viewer-core's.
87
+
88
+ function haystack(item, text) {
89
+ return [
90
+ text.name, text.description, text.short_description, text.type, text.holder, text.dates,
91
+ text.location, text.provenance, text.alternate_name, text.place_of_production,
92
+ ...(text.keywords ?? []), ...(text.materials ?? []),
93
+ item.internal_name, item.owner_reference, item.mwnf_reference,
94
+ labelOf('partners', item.partner_id), labelOf('countries', item.country_id),
95
+ ]
96
+ }
97
+
98
+ // ── The tile ──────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * A record as viewer-layout's grid contract: the thumbnail, the name, the
102
+ * lines legacy's hover card carried (date, holder, place, source
103
+ * project).
104
+ */
105
+ function tile(item, t) {
106
+ const text = tr('items', item.id, defaultLang)
107
+ return {
108
+ id: item.id,
109
+ image: item.images?.[0]?.url ?? '',
110
+ imageAlt: labelOf('items', item.id),
111
+ name: mdInline(text.name ?? item.internal_name ?? ''),
112
+ meta: [
113
+ text.dates ?? '',
114
+ labelOf('partners', item.partner_id),
115
+ [text.location, labelOf('countries', item.country_id)].filter(Boolean).join(', '),
116
+ `${t('catalogue.results.forProject')} ${projectLabel(manifest, item.project_id, defaultLang) ?? ''}`,
117
+ ].filter(Boolean),
118
+ to: itemRoute(item),
119
+ }
120
+ }
121
+
122
+ // ── The results page, as a spec ──────────────────────────────────────
123
+ //
124
+ // What viewer-layout's `CatalogueResultsView` renders on
125
+ // `/collection-results`: the facets over the *matching* records (the
126
+ // dependent dropdowns above), the containment date rule, undated first,
127
+ // nine tiles a page, legacy's "Collection | <selections>" summary line.
128
+ // The panel itself is composed by the view's wrapper, in the aside where
129
+ // legacy put it, so no controls are declared here. Every text is an entry
130
+ // name.
131
+
132
+ // `from`/`to`, not `start`/`end`: the entrance's `SearchFormView` spec
133
+ // (`mode: 'facets'`, `dates: 'buckets'`) writes the date bounds under
134
+ // those two fixed keys — the view's own, not something a spec can rename
135
+ // — so the results side has to read the same ones to stay linked to it.
136
+ const KEYS = ['country', ...FACET_CATEGORIES, 'from', 'to']
137
+
138
+ /** The filter summary line legacy printed as "Collection | <selections>". */
139
+ function filterSummary(filters, t) {
140
+ const parts = []
141
+ if (filters.country) parts.push(labelOf('countries', countryIdForCode(filters.country)))
142
+ for (const key of FACET_CATEGORIES) if (filters[key]) parts.push(tagLabelForLegacy(filters[key]))
143
+ if (filters.from) parts.push(`${t('catalogue.filter.from')} ${filters.from}`)
144
+ if (filters.to) parts.push(`${t('catalogue.filter.to')} ${filters.to}`)
145
+ return parts.filter(Boolean).join(' | ')
146
+ }
147
+
148
+ const collectionResults = {
149
+ entity: 'items',
150
+ keys: KEYS,
151
+ facets: FACETS,
152
+ facetScope: 'matching',
153
+ filterMode: 'immediate',
154
+ dates: { mode: DATE_MODE, begin: 'from', end: 'to' },
155
+ sort: { undated: 'first' },
156
+ pageSize: PAGE_SIZE,
157
+ variant: 'grid',
158
+ recordRoute: 'item',
159
+ actionLabel: 'gallery.action.seeDatabaseEntry',
160
+ empty: 'catalogue.results.noResults',
161
+ pagination: { jump: true },
162
+
163
+ record: (item, { t }) => tile(item, t),
164
+
165
+ summary: ({ filters, pageInfo, t, total }) => [
166
+ { label: t('gallery.section.collection'), value: filterSummary(filters, t) },
167
+ { count: pageInfo.total, value: `${t('catalogue.results.outOf')} ${total} ${t('catalogue.results.objects')}` },
168
+ ],
169
+ }
170
+
171
+ return {
172
+ FACETS, hasEveryTag, haystack, tile, collectionResults,
173
+ countryIdForCode, tagIdForLegacy, tagLabelForLegacy,
174
+ }
175
+ }