@museumwnf/viewer-core 2.2.0 → 2.3.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.
package/README.md CHANGED
@@ -566,6 +566,9 @@ below for whichever future site needs its own).
566
566
  | `useGalleryPartner(data, collection, config)` / `useExhibitionPartner(data, config)` | `data`, and (gallery only) `collection` for its `tile` | `partnerList`/`partnerListSpec`, `partnerSheet`/`partnerSheetSpec`, and (gallery only) `partnerObjects` — the exhibition shape's `partnerSpecs.js` never carried an objects-page spec |
567
567
  | `useGallerySheet(data, config)` / `useExhibitionSheet(data, config)` | `data`; `config` unused today | `{ itemSheet }`, the `RecordView` spec — the exhibition shape's `citation.project` reads `data.projectName`, absent from the gallery shape |
568
568
  | `PAGE_SIZE`, `DATE_MODE`, `FACET_CATEGORIES`, `FACET_LABEL_KEYS`, `useFacetLabels()` | — | the five THG catalogue facets, shared verbatim by both families (nine tiles a page, containment dates, `type`/`dynasty`/`subject`/`material`/`artist`) |
569
+ | `useGalleryItemDetail(data, timeline)` / `useExhibitionItemDetail(data, timeline)` | `data`, the family's `timeline` object; the site's `projectColors` and `noticeProjects` through `useSiteConfig()`, read lazily | `{ itemDetail }`, the item page's `RecordSheetView` spec: the family sheet plus the source-database chip, the Explore-partner notice, the holding-museum link (none for an exhibition's hidden partner), and the related block's labels, outside-reference chips, dynasty popouts and timeline popout. The exhibition gives a record with no project the `mwnf-chip--Explore` chip, and its "search this period" link carries only the timeline controls' keys |
570
+ | `useExhibitionThemes(data)` | `data` | the exhibition's themes: `themeTree` (a `useCollectionTree` over themes.json, `entity: 'themes'`), `themes`, `aboutTheme`, `listedThemes`, `themeByRouteId`/`themeRouteId` (legacy's `display_order - 1`), `romanFor`, `themePictures`, `pictureById`, `themeText`/`pictureText`, `owningTheme`, `subIndexOf`, `pictureParent`, `itemDetailString`, and `useThemePictures(node)`, the live `pictures` `PictureGallery`/`PictureNarrative` read |
571
+ | `buildThemePictures(nodePictures, deps)`, `romanFor(displayOrder)` | — | the pure halves of the above: a node's pictures with their `related`/`backRelated` links resolved across the whole tree, and legacy's Roman theme numeral (display order 2 is "I") |
569
572
 
570
573
  ### The declaration outside a component
571
574
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Application engine for MWNF websites: turns a dataset.config.js into a mounted Vue application.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,130 @@
1
+ import { eraLabel, roundOutward } from '../../catalogue/dates.js'
2
+ import { useSiteConfig } from '../../siteConfig.js'
3
+ import { useExhibitionSheet } from './useExhibitionSheet.js'
4
+
5
+ // An exhibition's item page, as viewer-layout's `RecordSheetView` spec
6
+ // (inventory-app#2053): `useExhibitionSheet`'s field sheet, plus the blocks
7
+ // the view reads through `spec.sourceDatabase`, `spec.notice`, `spec.museum`
8
+ // and `spec.related.*`. The six exhibitions each carried this layer as their
9
+ // own `composables/sheet.js`, the same code in all six.
10
+ //
11
+ // What stays an exhibition's own is read from its `dataset.config.js`
12
+ // through `useSiteConfig()`, lazily, as the galleries do:
13
+ //
14
+ // * `projectColors`, project id → one of viewer-layout's `mwnf-chip--*`
15
+ // classes, for the source-database chip and an outside reference's chip;
16
+ // a record with no project at all is an Explore record, whose chip is
17
+ // `mwnf-chip--Explore`;
18
+ // * `noticeProjects`, the projects whose sheets still carry legacy's
19
+ // Explore-partner notice.
20
+
21
+ /**
22
+ * The exhibition's item-page spec, over its data layer's `data` and its
23
+ * timeline layer's `timeline` (`useExhibitionTimeline`).
24
+ *
25
+ * Reads `data.defaultLang`, `data.dynastyById`, `data.isExploreRecord`,
26
+ * `data.isHiddenPartner`, `data.labelOf`, `data.partnerById`,
27
+ * `data.partnerRoute`, `data.translations`, what `useExhibitionSheet` reads,
28
+ * and `timeline.countryIdForCode`, `timeline.findEvents`,
29
+ * `timeline.hasTimeline`, `timeline.timelineCountries`, `timeline.timelineSpec`.
30
+ */
31
+ export function useExhibitionItemDetail(data, timeline) {
32
+ const {
33
+ defaultLang, dynastyById, isExploreRecord, isHiddenPartner, labelOf, partnerById, partnerRoute, translations,
34
+ } = data
35
+ const { countryIdForCode, findEvents, hasTimeline, timelineCountries, timelineSpec } = timeline
36
+ const { itemSheet: platformSheet } = useExhibitionSheet(data)
37
+
38
+ function chipClass(record) {
39
+ return useSiteConfig().projectColors?.[record.project_id] ?? (isExploreRecord(record) ? 'mwnf-chip--Explore' : null)
40
+ }
41
+
42
+ function dynastyTr(dynasty, language) {
43
+ return translations('dynasties', language)[dynasty.id] ?? translations('dynasties', defaultLang)[dynasty.id] ?? {}
44
+ }
45
+
46
+ // Legacy popped out a dynasty only when it had a history to show.
47
+ function dynastiesWithHistory(record, language) {
48
+ return (record.dynasty_ids ?? [])
49
+ .map((id) => dynastyById.value.get(id))
50
+ .filter((d) => d && dynastyTr(d, language).history)
51
+ }
52
+
53
+ // The country the "Timeline for this item" popout opens on: the legacy
54
+ // two-letter code whose id is the record's own country.
55
+ function countryCodeOf(countryId) {
56
+ for (const [code] of timelineCountries.value) {
57
+ if (countryIdForCode(code) === countryId) return code
58
+ }
59
+ return null
60
+ }
61
+
62
+ // The popout's "search this period" link: the timeline results filtered to
63
+ // the country and the period, under the keys the results page's controls
64
+ // read — never a key it has no control for (a local timeline has no
65
+ // country), which it would ignore (inventory-app#2022).
66
+ function periodSearch(countryCode, range) {
67
+ const keys = new Set((timelineSpec.value?.controls ?? []).map((control) => control.key))
68
+ const query = { country: countryCode, begin: range[0], end: range[1] }
69
+ return { name: 'timeline-results', query: Object.fromEntries(Object.entries(query).filter(([key]) => keys.has(key))) }
70
+ }
71
+
72
+ const itemDetail = {
73
+ ...platformSheet,
74
+
75
+ sourceDatabase: {
76
+ chipClass: (record) => chipClass(record),
77
+ },
78
+
79
+ notice: {
80
+ show: (record) => (useSiteConfig().noticeProjects ?? []).includes(record.project_id),
81
+ label: 'exhibition.item.explorePartnerNote',
82
+ },
83
+
84
+ // A hidden museum keeps its name on the sheet and loses the link: it has
85
+ // no page to link to.
86
+ museum: {
87
+ route: (partnerId) => {
88
+ const partner = partnerById.value.get(partnerId)
89
+ if (!partner || isHiddenPartner(partner)) return null
90
+ return partnerRoute(partner)
91
+ },
92
+ label: (partnerId) => labelOf('partners', partnerId),
93
+ },
94
+
95
+ related: {
96
+ ...platformSheet.related,
97
+ title: 'exhibition.related.title',
98
+ description: 'exhibition.related.description',
99
+ outsideChip: (ref) => chipClass(ref),
100
+ notInPackageLabel: 'exhibition.results.notInThisExhibition',
101
+ artisticIntroductionLabel: 'exhibition.nav.artisticIntroduction',
102
+ databaseLabel: 'exhibition.search.relatedDatabase',
103
+ overallDatabase: { label: 'exhibition.search.overallDatabase', linkLabel: 'exhibition.nav.overallDatabase' },
104
+ onDisplayIn: { linkPendingLabel: 'exhibition.item.linkPending' },
105
+ dynasties: (record, language) => ({
106
+ records: dynastiesWithHistory(record, language),
107
+ tr: (d) => dynastyTr(d, language),
108
+ }),
109
+ // Withheld when the exhibition reports no chronology, or the item has
110
+ // no date to anchor a range on: legacy then printed no "timeline"
111
+ // anywhere on the sheet.
112
+ timeline: (record, ctx) => {
113
+ if (!hasTimeline.value) return null
114
+ const range = roundOutward(record.start_date, record.end_date)
115
+ if (range[0] == null) return null
116
+ return {
117
+ heading: 'exhibition.section.timeline',
118
+ countries: timelineCountries.value.map(([value, label]) => ({ value, label })),
119
+ defaultCountry: () => countryCodeOf(record.country_id) ?? 'all',
120
+ events: (countryCode) => findEvents({ countryCode, start: range[0], end: range[1] }),
121
+ range,
122
+ era: (year) => eraLabel(year, ctx.t),
123
+ searchTo: periodSearch,
124
+ }
125
+ },
126
+ },
127
+ }
128
+
129
+ return { itemDetail }
130
+ }
@@ -0,0 +1,279 @@
1
+ import { computed } from 'vue'
2
+ import { useI18n } from '../../i18n/index.js'
3
+ import { entityRef } from '../../composables/useEntities.js'
4
+ import { useCollectionTree } from '../../record/collectionTree.js'
5
+
6
+ // An exhibition's themes: the tour its theme pages, its themes list and its
7
+ // theme galleries read (inventory-app#2053). The six exhibitions each carried
8
+ // this as `composables/themes.js`, `useThemePresentation.js` and
9
+ // `useThemePictures.js`, the same code in all six.
10
+ //
11
+ // themes.json is the ordered tree: top-level themes, each with its sub-themes
12
+ // and its curated picture selections. `useCollectionTree({ source: 'themes' })`
13
+ // walks it, and its `previous`/`next` (depth-first) is legacy's tour order:
14
+ // About → theme 1 overview → its sub-themes → theme 2 overview → ….
15
+ //
16
+ // Two rules the data fixes rather than taste:
17
+ //
18
+ // * Theme 0 ("About the Exhibition") is an ordinary top-level theme legacy
19
+ // renders at /about and skips on /themes. Its display order is 1, so the
20
+ // themes list starts at display order 2 and numbers those "Theme I"
21
+ // upwards — which is why `romanFor` subtracts one.
22
+ // * The theme id in the keyspace is not the display order. The route
23
+ // carries `display_order - 1`, as legacy's `theme.display - 1` did, so a
24
+ // legacy address pasted after the `#` lands on the same theme.
25
+ //
26
+ // A theme's selections point at `picture` items, which are not members of the
27
+ // exhibition and so not in items.json — only their parents are. Every label a
28
+ // theme page shows (name, holding museum, location, country) is read off the
29
+ // parent record, which is also what "see the full record" links to.
30
+ // `parent_in_package` says whether the parent is there at all: a curated
31
+ // picture whose parent was not exported still renders its own image.
32
+
33
+ /**
34
+ * Legacy numbered its themes in Roman numerals, counting from the About
35
+ * theme. EssayView's `numbering: 'roman'` counts a node's position among its
36
+ * siblings, which it derives from `tree.root` — always null for a themes.json
37
+ * package — so it would number every top-level theme "I". The numeral an
38
+ * exhibition shows is always the owning theme's, so one function serves
39
+ * themes and sub-themes.
40
+ */
41
+ export function romanFor(displayOrder) {
42
+ const lookup = [
43
+ ['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90],
44
+ ['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1],
45
+ ]
46
+ let n = displayOrder - 1
47
+ let out = ''
48
+ for (const [sym, value] of lookup) {
49
+ while (n >= value) { out += sym; n -= value }
50
+ }
51
+ return out
52
+ }
53
+
54
+ /**
55
+ * The `pictures` PictureGallery/PictureNarrative read (viewer-layout's
56
+ * docs/theme-components.md) for one node, as a pure function of its data so
57
+ * the graph rules are tested without Vue or a package.
58
+ *
59
+ * A picture's `related`/`backRelated` targets resolve against `pictureById`,
60
+ * the WHOLE tree's picture index, not the node's own selections:
61
+ * `theme_item_related` rows can name a picture curated under another theme,
62
+ * and scoping the lookup to the node is what used to drop such a link.
63
+ *
64
+ * @param nodePictures - the node's own selections, in curator order
65
+ * @param deps.pictureById - Map<pictureId, rawPicture> for every picture in the tree
66
+ * @param deps.resolvePicture - (raw) => `{ id, image, imageAlt, name, detail, to }`,
67
+ * the node-independent shape, used for the node's pictures and every target
68
+ * @param deps.imageCaptionFor - (raw) => this node's own caption for the picture
69
+ * @param deps.fieldsFor - (raw) => this node's panel `fields` for the picture
70
+ * @param deps.relationText - (link) => the forward relation's text
71
+ * @param deps.reciprocalText - (link) => the backward relation's text
72
+ */
73
+ export function buildThemePictures(nodePictures, deps) {
74
+ const {
75
+ pictureById: byId, resolvePicture, imageCaptionFor, fieldsFor, relationText, reciprocalText,
76
+ } = deps
77
+
78
+ return (nodePictures ?? []).map((raw) => {
79
+ const related = (raw.related ?? [])
80
+ .map((link) => {
81
+ const target = byId.get(link.picture_item_id)
82
+ return target ? { picture: resolvePicture(target), text: relationText(link) } : null
83
+ })
84
+ .filter(Boolean)
85
+
86
+ // Every other picture anywhere in the tree whose own `related` names this
87
+ // one, so a target on another theme's page still shows "related to" back.
88
+ const backRelated = []
89
+ for (const other of byId.values()) {
90
+ for (const link of other.related ?? []) {
91
+ if (link.picture_item_id === raw.picture_item_id) {
92
+ backRelated.push({ picture: resolvePicture(other), reciprocalText: reciprocalText(link) })
93
+ }
94
+ }
95
+ }
96
+
97
+ return {
98
+ ...resolvePicture(raw),
99
+ imageCaption: imageCaptionFor(raw),
100
+ fields: fieldsFor(raw),
101
+ // Not part of the components' contract: the theme page turns a
102
+ // selection back into legacy's `?image=<display_order>` with it.
103
+ displayOrder: raw.display_order,
104
+ related,
105
+ backRelated,
106
+ }
107
+ })
108
+ }
109
+
110
+ /**
111
+ * The exhibition's themes, over its data layer's `data`.
112
+ *
113
+ * Reads `data.defaultLang`, `data.itemById`, `data.itemRoute`, `data.labelOf`,
114
+ * `data.mdInline`, `data.tr`.
115
+ */
116
+ export function useExhibitionThemes(data) {
117
+ const { defaultLang, itemById, itemRoute, labelOf, mdInline, tr } = data
118
+
119
+ // EssayView reads `spec.tree.entity` to know where a node's own text lives,
120
+ // even for a tree built here rather than from its `{ themes: true }`
121
+ // shorthand; without it, every title/quote/body lookup would read
122
+ // 'collections', not a themes.json package's entity.
123
+ const themeTree = useCollectionTree({ source: 'themes', entity: 'themes' })
124
+ themeTree.entity = 'themes'
125
+
126
+ // The raw top-level array, in the order the themes list and the tour read
127
+ // them: the tree's own `root` is always null for this shape, so it cannot
128
+ // hand back every top-level theme itself.
129
+ const rawThemes = entityRef('themes')
130
+ const themes = computed(() => rawThemes.value ?? [])
131
+
132
+ const aboutTheme = computed(() => themes.value.find((t) => t.display_order === 1) ?? null)
133
+
134
+ /** The themes the themes page lists: everything after the About theme. */
135
+ const listedThemes = computed(() => themes.value.filter((t) => t.display_order > 1))
136
+
137
+ /** Route id → theme: legacy's `/theme/:id` carries `display_order - 1`. */
138
+ function themeByRouteId(id) {
139
+ const n = Number(id)
140
+ return themes.value.find((t) => t.display_order - 1 === n) ?? null
141
+ }
142
+
143
+ function themeRouteId(theme) {
144
+ return (theme?.display_order ?? 1) - 1
145
+ }
146
+
147
+ /** A node's picture selections, in curator order. */
148
+ function themePictures(theme) {
149
+ return [...(theme?.pictures ?? [])].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
150
+ }
151
+
152
+ /** Every picture selection in the tree, by its own id (not a catalogue id). */
153
+ const pictureById = computed(() => {
154
+ const map = new Map()
155
+ for (const node of themeTree.byId.value.values()) {
156
+ for (const picture of node.pictures ?? []) map.set(picture.picture_item_id, picture)
157
+ }
158
+ return map
159
+ })
160
+
161
+ // translations/themes.<lang>.json is keyed two ways: by theme id for the
162
+ // theme's own title/quote/presentation, and by `<theme id>/<picture item
163
+ // id>` for one picture's curated text in that theme — the same picture in
164
+ // two themes carries two descriptions.
165
+ function themeText(theme, lang = defaultLang) {
166
+ return tr('themes', theme?.id, lang)
167
+ }
168
+
169
+ function pictureText(theme, picture, lang = defaultLang) {
170
+ if (!theme?.id || !picture?.picture_item_id) return {}
171
+ return tr('themes', `${theme.id}/${picture.picture_item_id}`, lang)
172
+ }
173
+
174
+ /** The top-level theme that owns a node: the node itself, or its parent. */
175
+ function owningTheme(node) {
176
+ if (!node) return null
177
+ const parents = themeTree.parents(node.id)
178
+ return parents.length ? parents[0] : node
179
+ }
180
+
181
+ /** A node's 1-based position among its owning theme's sub-themes; null for the theme itself. */
182
+ function subIndexOf(node) {
183
+ if (!node) return null
184
+ const owner = owningTheme(node)
185
+ if (!owner || owner.id === node.id) return null
186
+ const index = themeTree.children(owner.id).findIndex((child) => child.id === node.id)
187
+ return index === -1 ? null : index + 1
188
+ }
189
+
190
+ /** A picture selection's parent record, or null when it is not a member. */
191
+ function pictureParent(picture) {
192
+ if (!picture?.parent_in_package) return null
193
+ return itemById.value.get(picture.parent_item_id) ?? null
194
+ }
195
+
196
+ /** Legacy's `itemDetailString`: museum, location, country, blanks dropped. */
197
+ function itemDetailString(item) {
198
+ if (!item) return ''
199
+ const sheet = tr('items', item.id, defaultLang)
200
+ return [
201
+ sheet.holder || labelOf('partners', item.partner_id),
202
+ sheet.location,
203
+ labelOf('countries', item.country_id),
204
+ ].filter(Boolean).join(', ')
205
+ }
206
+
207
+ /**
208
+ * A node's `pictures`, live: its selections resolved against the parent
209
+ * records, the translations and the current locale. Call it in a
210
+ * component's setup; `node` is a ref.
211
+ */
212
+ function useThemePictures(node) {
213
+ const { t, locale } = useI18n()
214
+
215
+ function resolvePicture(raw) {
216
+ const parent = pictureParent(raw)
217
+ const name = parent ? labelOf('items', parent.id) : ''
218
+ return {
219
+ id: raw.picture_item_id,
220
+ image: raw.image_url,
221
+ imageAlt: name,
222
+ name: mdInline(name),
223
+ detail: itemDetailString(parent),
224
+ to: parent ? itemRoute(parent) : null,
225
+ }
226
+ }
227
+
228
+ function imageCaptionFor(raw) {
229
+ if (!node.value?.id) return ''
230
+ return tr('themes', `${node.value.id}/${raw.picture_item_id}`, locale.value).image_caption ?? ''
231
+ }
232
+
233
+ // Legacy labelled only "also known as"; artist names and dates stand
234
+ // alone, as `fields` rows with an empty label.
235
+ function fieldsFor(raw) {
236
+ const parent = pictureParent(raw)
237
+ if (!parent) return []
238
+ const sheet = tr('items', parent.id, defaultLang)
239
+ const fields = []
240
+ if (sheet.alternate_name) {
241
+ fields.push({ label: `${t('sheet.field.alsoKnownAs')}:`, value: mdInline(sheet.alternate_name) })
242
+ }
243
+ if (parent.artist_names?.length) fields.push({ label: '', value: parent.artist_names.join(', ') })
244
+ if (sheet.dates) fields.push({ label: '', value: sheet.dates })
245
+ return fields
246
+ }
247
+
248
+ const relationText = (link) => link?.descriptions?.[locale.value] ?? link?.descriptions?.en ?? ''
249
+ const reciprocalText = (link) => link?.reciprocal_descriptions?.[locale.value] ?? link?.reciprocal_descriptions?.en ?? ''
250
+
251
+ return computed(() => buildThemePictures(themePictures(node.value), {
252
+ pictureById: pictureById.value,
253
+ resolvePicture,
254
+ imageCaptionFor,
255
+ fieldsFor,
256
+ relationText,
257
+ reciprocalText,
258
+ }))
259
+ }
260
+
261
+ return {
262
+ themeTree,
263
+ themes,
264
+ aboutTheme,
265
+ listedThemes,
266
+ themeByRouteId,
267
+ themeRouteId,
268
+ romanFor,
269
+ themePictures,
270
+ pictureById,
271
+ themeText,
272
+ pictureText,
273
+ owningTheme,
274
+ subIndexOf,
275
+ pictureParent,
276
+ itemDetailString,
277
+ useThemePictures,
278
+ }
279
+ }
@@ -0,0 +1,110 @@
1
+ import { eraLabel, roundOutward } from '../../catalogue/dates.js'
2
+ import { useSiteConfig } from '../../siteConfig.js'
3
+ import { useGallerySheet } from './useGallerySheet.js'
4
+
5
+ // A gallery's item page, as viewer-layout's `RecordSheetView` spec
6
+ // (inventory-app#2054): `useGallerySheet`'s field sheet, plus the blocks the
7
+ // view reads through `spec.sourceDatabase`, `spec.notice`, `spec.museum` and
8
+ // `spec.related.*` — the project chips, the Explore-partner notice, the
9
+ // holding-museum link, and the related block's outside references,
10
+ // cross-database links, dynasty popouts and timeline popout. The 37
11
+ // galleries each carried this layer in their own `composables/gallery.js`,
12
+ // the same code in all 37.
13
+ //
14
+ // What stays a gallery's own is read from its `dataset.config.js` through
15
+ // `useSiteConfig()`, lazily, inside the functions the view calls at render
16
+ // time — `dataset.config.js` is imported before `createViewer()` sets the
17
+ // config, so a read at module level would only ever see the empty default:
18
+ //
19
+ // * `projectColors`, project id → one of viewer-layout's `mwnf-chip--*`
20
+ // classes, for the source-database chip and an outside reference's chip;
21
+ // * `noticeProjects`, the projects whose sheets still carry legacy's
22
+ // Explore-partner notice.
23
+
24
+ /**
25
+ * The gallery's item-page spec, over its data layer's `data` and its
26
+ * timeline layer's `timeline` (`useGalleryTimeline`).
27
+ *
28
+ * Reads `data.defaultLang`, `data.dynastyById`, `data.labelOf`,
29
+ * `data.partnerById`, `data.partnerRoute`, `data.translations`, what
30
+ * `useGallerySheet` reads, and `timeline.countryIdForCode`,
31
+ * `timeline.timelineEvents` (`countries`, `findEvents`).
32
+ */
33
+ export function useGalleryItemDetail(data, timeline) {
34
+ const { defaultLang, dynastyById, labelOf, partnerById, partnerRoute, translations } = data
35
+ const { countryIdForCode, timelineEvents } = timeline
36
+ const { countries: timelineCountries, findEvents } = timelineEvents
37
+ const { itemSheet: platformSheet } = useGallerySheet(data)
38
+
39
+ const chipClass = (record) => useSiteConfig().projectColors?.[record.project_id] ?? null
40
+
41
+ function museumRoute(partnerId) {
42
+ const partner = partnerById.value.get(partnerId)
43
+ return partner ? partnerRoute(partner) : null
44
+ }
45
+
46
+ function dynastyTranslation(dynasty, language) {
47
+ return translations('dynasties', language)[dynasty.id] ?? translations('dynasties', defaultLang)[dynasty.id] ?? {}
48
+ }
49
+
50
+ // Legacy popped out a dynasty only when it had a history to show.
51
+ function itemDynasties(record, language) {
52
+ const records = (record.dynasty_ids ?? [])
53
+ .map((id) => dynastyById.value.get(id))
54
+ .filter((d) => d && dynastyTranslation(d, language).history)
55
+ return records.length ? { records, tr: (d) => dynastyTranslation(d, language) } : null
56
+ }
57
+
58
+ // The timeline popout, for the record's own date range: the gallery's own
59
+ // countries, events and search route.
60
+ function itemTimeline(record, ctx) {
61
+ const [from, to] = roundOutward(record.start_date, record.end_date)
62
+ if (from == null) return null
63
+ return {
64
+ heading: 'gallery.section.timeline',
65
+ countries: timelineCountries.value.map((c) => ({ value: c.value, label: c.label ?? ctx.t('timeline.form.allCountries') })),
66
+ defaultCountry: () => {
67
+ for (const { value: code } of timelineCountries.value) {
68
+ if (countryIdForCode(code) === record.country_id) return code
69
+ }
70
+ return 'all'
71
+ },
72
+ events: (country) => findEvents({ country, begin: from, end: to }),
73
+ range: [from, to],
74
+ era: (year) => eraLabel(year, ctx.t),
75
+ searchTo: (country, [begin, end]) => ({ name: 'timeline-results', query: { country, begin, end } }),
76
+ }
77
+ }
78
+
79
+ const itemDetail = {
80
+ ...platformSheet,
81
+ sourceDatabase: {
82
+ chipClass,
83
+ },
84
+ notice: {
85
+ show: (record) => (useSiteConfig().noticeProjects ?? []).includes(record.project_id),
86
+ label: 'gallery.item.explorePartnerNote',
87
+ },
88
+ museum: {
89
+ route: (partnerId) => museumRoute(partnerId),
90
+ label: (partnerId) => labelOf('partners', partnerId),
91
+ },
92
+ related: {
93
+ ...platformSheet.related,
94
+ title: 'gallery.related.title',
95
+ description: 'gallery.related.description',
96
+ notInPackageLabel: 'gallery.results.notInThisGallery',
97
+ // An outside `related_items` reference carries its `project_id`
98
+ // (inventory-app#1807), so it reads the same `projectColors` map.
99
+ outsideChip: chipClass,
100
+ artisticIntroductionLabel: 'gallery.nav.artisticIntroduction',
101
+ databaseLabel: 'gallery.search.relatedDatabase',
102
+ overallDatabase: { label: 'gallery.search.overallDatabase', linkLabel: 'gallery.nav.overallDatabase' },
103
+ onDisplayIn: { linkPendingLabel: 'gallery.item.linkPending' },
104
+ dynasties: (record, language) => itemDynasties(record, language),
105
+ timeline: itemTimeline,
106
+ },
107
+ }
108
+
109
+ return { itemDetail }
110
+ }
package/src/dxa/index.js CHANGED
@@ -26,9 +26,12 @@ export { useGalleryCollection } from './gallery/useGalleryCollection.js'
26
26
  export { useGalleryTimeline } from './gallery/useGalleryTimeline.js'
27
27
  export { useGalleryPartner } from './gallery/useGalleryPartner.js'
28
28
  export { useGallerySheet } from './gallery/useGallerySheet.js'
29
+ export { useGalleryItemDetail } from './gallery/useGalleryItemDetail.js'
29
30
 
30
31
  export { useExhibitionData } from './exhibition/useExhibitionData.js'
31
32
  export { useExhibitionCollection } from './exhibition/useExhibitionCollection.js'
32
33
  export { useExhibitionTimeline } from './exhibition/useExhibitionTimeline.js'
33
34
  export { useExhibitionPartner } from './exhibition/useExhibitionPartner.js'
34
35
  export { useExhibitionSheet } from './exhibition/useExhibitionSheet.js'
36
+ export { useExhibitionItemDetail } from './exhibition/useExhibitionItemDetail.js'
37
+ export { buildThemePictures, romanFor, useExhibitionThemes } from './exhibition/useExhibitionThemes.js'