@museumwnf/viewer-core 2.1.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
@@ -470,14 +470,14 @@ under the main partner it belongs to.
470
470
  | --- | --- |
471
471
  | `groupByCountry(records, { label, tier?, order = 'asc' })` | `[{ country, label, main, associated }]`, one entry per distinct `country_id`, sorted by `label`. `label(country)` names a group's country, the same shape as a facet's `label(value)`. `tier` names the field that marks a main partner (the converged shape's `level`); a record is associated only when its tier value is a non-empty value other than `'partner'` (legacy values: `'associated_partner'`, `'minor_contributor'`); `null`, `undefined`, `''`, or `'partner'` mean main. Without a `tier` every record is main, which is the DXA lists' plain grouping. Partners keep the order `records` was given in within each tier. |
472
472
  | `partnerHierarchy(partners)` | `{ children(id), parentOf(id), roots }` from `parent_id`: `children(id)` a partner's associated partners, `parentOf(id)` the main partner it belongs to (or null), `roots` every partner with no parent in the list or whose `parent_id` points outside it. |
473
- | `partnerView(partner, text, ctx)` | The view-model every rendering of one partner reads — `@museumwnf/viewer-layout`'s `PartnerPanel` in each of its variants: `{ id, type, name (inline HTML), plainName, city, country, location, logos: [{ url, alt, type }], pictures: [{ url, alt, caption, photographer, copyright }], description (block HTML), contact: { address (block HTML), phone, fax, email, website: { url, label } \| null, links: [{ url, label }] }, persons: [{ title, name, phone, fax, email }], hasContact, map: { latitude, longitude, zoom, label } \| null, itemCount, hidden, route, objectsRoute }`. `text` is the partner's translation in the language shown. `ctx` carries only what differs by family: `countryLabel(id)`, the renderers (`md`, `mdInline`, `mdStrip`, the package's by default), `route(partner)`, `objectsRoute(partner)` and `hidden(partner)` (a hidden partner keeps its name and loses both links). Contact persons come from `contact_persons`, in order, keeping those with a name or a title. A picture's caption is its `alt_text`. A website or link with no scheme gets `https://`; any scheme other than `http(s)` is dropped. |
473
+ | `partnerView(partner, text, ctx)` | The view-model every rendering of one partner reads — `@museumwnf/viewer-layout`'s `PartnerPanel` in each of its variants: `{ id, type, name (inline HTML), plainName, city, country, location, logos: [{ url, alt, type }], pictures: [{ url, alt, caption, photographer, copyright }], description (block HTML), contact: { address (block HTML), phone, fax, email, website: { url, label } \| null, links: [{ url, label }] }, persons: [{ title, name, phone, fax, email }], hasContact, map: { latitude, longitude, zoom, label } \| null, itemCount, hidden, route, objectsRoute }`. `text` is the partner's translation in the language shown. `ctx` carries only what differs by family: `countryLabel(id)`, the renderers (`md`, `mdInline`, `mdStrip`, the package's by default), `route(partner)`, `objectsRoute(partner)` and `hidden(partner)` (a hidden partner keeps its name and loses both links). Contact persons come from `contact_persons`, in order, keeping those with a name or a title (the legacy `contact_person_1`/`_2` pair is not read). A picture's caption is its `alt_text`. A website or link with no scheme gets `https://`; any scheme other than `http(s)` is dropped. |
474
474
 
475
475
  ### Conventions
476
476
 
477
477
  | Export | Meaning |
478
478
  | --- | --- |
479
479
  | `useSection()` | the `meta.section` of the current route — see Routing |
480
- | `useFeaturedRecord(entity, { withImage = true, seed })` | one record at random for a landing page's spotlight, among those with an image; null until the entity is loaded; `seed` pins the pick |
480
+ | `useFeaturedRecord(entity, { withImage = true, seed, filter })` | one record at random for a landing page's spotlight, among those with an image; null until the entity is loaded; `seed` pins the pick; `filter(record)` applies a site's own visible rule first |
481
481
  | `sectionMeta(chrome = [])` | returns `meta(section, ...entities) => ({ section, entities: [...chrome, ...entities] })` — a route's `meta` in one call, `chrome` being the entities every page of the site loads regardless of which one it is |
482
482
  | `mwnfLinks` | the portal and sibling-site addresses the four DXA configs each repeat under `links` — frozen, spread into a site's own `links` rather than retyped |
483
483
 
@@ -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
 
@@ -638,6 +641,49 @@ export const { tr, md, mdInline, mdStrip, labelOf, loadEnglish } = catalogue
638
641
  | `index(name, key = 'id')` | a `Map` of `entity(name)`'s records by `key` — the visible ones, unlike `byId` |
639
642
  | `availableLanguages`, `loadTranslations`, `translations` | the package's own, re-exported so a site reads everything through the one object |
640
643
 
644
+ ### The catalogue data layer
645
+
646
+ `useCatalogue` is `useCatalogueData` plus what a catalogue site — a
647
+ standalone product, or a new product scaffolded from `website-template` —
648
+ otherwise re-types for itself: the catalogue entities, their lookups, the
649
+ labels, the routes and the one result row. A site calls it once, in its
650
+ `data.js`, and keeps next to it only what is its own (a scope rule, a
651
+ collection tree, its legacy address mappings).
652
+
653
+ ```js
654
+ import { useCatalogue } from '@museumwnf/viewer-core'
655
+
656
+ export const data = useCatalogue({
657
+ eager: ['items', 'countries', 'partners', 'dynasties', 'glossary'],
658
+ visible: { items: (item) => item.display_status !== 'N' },
659
+ })
660
+ data.loadEnglish()
661
+
662
+ export const { items, itemRow, countryLabel, tr, md, mdInline } = data
663
+ ```
664
+
665
+ | Export | Meaning |
666
+ | --- | --- |
667
+ | `useCatalogue(options)` | `useCatalogueData(options)`'s own return, plus: `items`, `countries`, `partners`, `dynasties` (the visible records, `null` until loaded); `itemById` (every item, the visible rule not applied — for a page that deliberately shows a hidden one), `partnerById`, `countryById`, `dynastyById`; `itemLabel(item)`, `countryLabel(id)`, `partnerLabel(id)`, `dynastyLabel(id)` (plain text, `''` for nothing); `itemRoute(item)`, `partnerRoute(partner)` (the `item`/`partner` routes by name); `itemRow(item, meta = ['country', 'dates'])` — the row `RecordList`/`RecordGrid`/`RelatedRecords` share, `meta` naming what follows the name in order: `'country'`, `'dates'`, `'location'`, `'dynasties'`, `'holder'` (the partner, only when the package carries it), or a function `(item, text) => string` |
668
+ | `CATALOGUE_PAGE_SIZE`, `CATALOGUE_DATE_MODE` | `20` and `'overlap'`: the standalone pages' size and date rule (decision D5) — named apart from `/dxa`'s `PAGE_SIZE`/`DATE_MODE` (`9`, `'contain'`), which a DXA site imports next to this root |
669
+ | `objectsAndMonumentsSummary({ matching, t })` | legacy's "[N objects, M monuments]", a results spec's `summary` |
670
+
671
+ The field search of the legacy `database.php` form — keyword rows each
672
+ naming a field, folded with AND/OR — around `useKeywordIndex`'s `fields`
673
+ grammar:
674
+
675
+ | Export | Meaning |
676
+ | --- | --- |
677
+ | `searchFields({ dynastyLabel })` | the haystack of each field, as legacy's form searched it: `keyword`, `name`, `location`, `provenance`, `patron`, `artist`, `material`, `other`, and `dynasty` when a `dynastyLabel` is given |
678
+ | `searchFieldOptions(fields)` | `[{ key, label }]` (entry names), in legacy's order, for the fields `fields` carries — `SearchFormView`'s `fields` |
679
+ | `useSearchFieldOptions(fields)`, `searchFieldLabel(key, t)` | the same resolved, `[{ value, label }]`, for a select a site draws itself; one field's label |
680
+ | `searchRows(filters, count = 4)`, `searchRowKeys(count = 4)` | the keyword rows the query carries (`q`/`field`, then `qN`/`fieldN`/`opN`), and their query keys for a results spec's `keys` |
681
+ | `searchedFor(filters, t, { rows, extras })`, `searchSummary(ctx, options)` | the "Name: "bowl" · OR Location: "Cairo" · From 900 · Language: FR" line — `extras`, `(filters, t) => string`, append a site's own parts — and the results summary built on it (what was searched, or all items; then the count) |
682
+ | `useFieldSearch({ fields, entity = 'items', rows = 4, rank = 'hits', expand })` | one long-lived index (the glossary and country expansions by default), its search language following the query's `lang`; `narrow(list, filters)` is a results spec's `narrow` — the matches in rank order, kept to the scoped `list`. Pass `sort: false` to the spec. |
683
+
684
+ `useFeaturedRecord(entity, { filter })` narrows the candidates by a site's
685
+ own visible rule first: it reads the raw entity.
686
+
641
687
  ### Routing
642
688
 
643
689
  The convention, applied by the router every website shares:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@museumwnf/viewer-core",
3
- "version": "2.1.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,128 @@
1
+ import { useCatalogueData } from '../composables/useCatalogueData.js'
2
+ import { byId } from '../composables/useEntities.js'
3
+
4
+ // The catalogue data layer: what a website built on the generic entries —
5
+ // the standalone products, and every new product scaffolded from
6
+ // `website-template` — re-typed around `useCatalogueData` for itself, three
7
+ // times (inventory-app#2037): the entity refs and lookups a catalogue reads,
8
+ // the label and route helpers, the one result row every list shows, the
9
+ // "[N objects, M monuments]" count. A site keeps one instance of it, in its
10
+ // `data.js`, next to what is genuinely its own (a scope rule, a collection
11
+ // tree, its legacy address mappings).
12
+ //
13
+ // The DXA families have their own layer (`@museumwnf/viewer-core/dxa`), over
14
+ // the same `useCatalogueData`; nothing here reads it.
15
+
16
+ /** Twenty rows a page: the legacy standalone pages' own size. */
17
+ export const CATALOGUE_PAGE_SIZE = 20
18
+
19
+ /**
20
+ * The standalone date rule (decision D5): a record overlapping the window
21
+ * counts, a record with one date is tolerated. The DXA families contain
22
+ * (`@museumwnf/viewer-core/dxa`'s `DATE_MODE`); a site names its rule once.
23
+ */
24
+ export const CATALOGUE_DATE_MODE = 'overlap'
25
+
26
+ /**
27
+ * The website's catalogue: everything `useCatalogueData` returns, plus the
28
+ * catalogue entities, their lookups, the labels, the routes and the result
29
+ * row. Takes `useCatalogueData`'s own options (`eager`, `defaultLanguage`,
30
+ * `visible`, `glossary`); called once, at the top of the site's `data.js`.
31
+ *
32
+ * - `items`, `countries`, `partners`, `dynasties`: the visible records
33
+ * (`visible` applies), `null` until loaded.
34
+ * - `itemById`: every item, the visible rule not applied — a page that
35
+ * deliberately shows a hidden item (a timeline's illustration) reads it.
36
+ * `partnerById`, `countryById`, `dynastyById`: the visible records.
37
+ * - `itemLabel(item)`, `countryLabel(id)`, `partnerLabel(id)`,
38
+ * `dynastyLabel(id)`: plain text, `''` for nothing.
39
+ * - `itemRoute(item)`, `partnerRoute(partner)`: the `item` and `partner`
40
+ * routes by name.
41
+ * - `itemRow(item, meta)`: the row `RecordList`/`RecordGrid`/
42
+ * `RelatedRecords` share. `meta` lists what follows the name, in order:
43
+ * `'country'`, `'dates'`, `'location'`, `'dynasties'`, `'holder'` (the
44
+ * partner's name, only when the package carries the partner, so a label
45
+ * is never an id), or a function `(item, text) => string`.
46
+ */
47
+ export function useCatalogue(options = {}) {
48
+ const data = useCatalogueData(options)
49
+ const { tr, labelOf, mdInline } = data
50
+
51
+ const items = data.entity('items')
52
+ const countries = data.entity('countries')
53
+ const partners = data.entity('partners')
54
+ const dynasties = data.entity('dynasties')
55
+
56
+ const itemById = byId('items')
57
+ const partnerById = data.index('partners')
58
+ const countryById = data.index('countries')
59
+ const dynastyById = data.index('dynasties')
60
+
61
+ const itemLabel = (item) => (item ? labelOf('items', item.id) : '')
62
+ const countryLabel = (id) => (id ? labelOf('countries', id) : '')
63
+ const partnerLabel = (id) => (id ? labelOf('partners', id) : '')
64
+ const dynastyLabel = (id) => (id ? labelOf('dynasties', id) : '')
65
+
66
+ const itemRoute = (item) => ({ name: 'item', params: { id: item.id } })
67
+ const partnerRoute = (partner) => ({ name: 'partner', params: { id: partner.id } })
68
+
69
+ const META = {
70
+ country: (item) => countryLabel(item.country_id),
71
+ dates: (item, text) => text.dates,
72
+ location: (item, text) => text.location,
73
+ dynasties: (item) => (item.dynasty_ids ?? []).map(dynastyLabel).filter(Boolean).join(', '),
74
+ holder: (item) => (partnerById.value.has(item.partner_id) ? partnerLabel(item.partner_id) : ''),
75
+ }
76
+
77
+ function itemRow(item, meta = ['country', 'dates']) {
78
+ const text = tr('items', item.id)
79
+ return {
80
+ id: item.id,
81
+ image: item.images?.[0]?.url ?? '',
82
+ imageAlt: itemLabel(item),
83
+ name: mdInline(text.name ?? item.internal_name ?? item.id),
84
+ meta: meta
85
+ .map((part) => (typeof part === 'function' ? part(item, text) : META[part]?.(item, text)))
86
+ .filter(Boolean),
87
+ badge: item.type ?? '',
88
+ to: itemRoute(item),
89
+ }
90
+ }
91
+
92
+ return {
93
+ ...data,
94
+ items,
95
+ countries,
96
+ partners,
97
+ dynasties,
98
+ itemById,
99
+ partnerById,
100
+ countryById,
101
+ dynastyById,
102
+ itemLabel,
103
+ countryLabel,
104
+ partnerLabel,
105
+ dynastyLabel,
106
+ itemRoute,
107
+ partnerRoute,
108
+ itemRow,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Legacy's count, "[N objects, M monuments]", over what matched: a results
114
+ * spec's `summary`. A monument is a record of type `monument`; everything
115
+ * else counts as an object.
116
+ */
117
+ export function objectsAndMonumentsSummary({ matching, t }) {
118
+ let objects = 0
119
+ let monuments = 0
120
+ for (const item of matching ?? []) {
121
+ if (item.type === 'monument') monuments++
122
+ else objects++
123
+ }
124
+ return [
125
+ { label: t('catalogue.results.objectsFound'), count: objects },
126
+ { label: t('catalogue.results.monumentsFound'), count: monuments },
127
+ ]
128
+ }
@@ -0,0 +1,176 @@
1
+ import { computed, ref } from 'vue'
2
+ import { useI18n } from '../i18n/index.js'
3
+ import { combineExpansions, countryExpansion, glossaryExpansion, useKeywordIndex } from './keywordIndex.js'
4
+
5
+ // The field search of the legacy `database.php` form — keyword rows, each
6
+ // naming a field, folded with AND/OR — which the three standalone products
7
+ // each wired for themselves (inventory-app#2037): the fields' haystacks, the
8
+ // field options and their labels, the rows the query string carries, the
9
+ // "searched for" line, the index. `useKeywordIndex`'s `fields` grammar is
10
+ // the engine; this is the rest of the machinery around it, written once.
11
+
12
+ /**
13
+ * What each field of the form searches, field for field as legacy's form
14
+ * did. `text` is the record's translation in the search language, English
15
+ * behind it. `keyword` and `location` also carry the record's `country_id`:
16
+ * `countryExpansion` turns a typed country name into its id, and a field is
17
+ * only found by that expansion if its haystack carries the id.
18
+ *
19
+ * `dynastyLabel(id)` turns on the `dynasty` field — Islamic Art's form has
20
+ * it, Baroque Art's and Sharing History's do not.
21
+ */
22
+ export function searchFields({ dynastyLabel } = {}) {
23
+ const fields = {
24
+ keyword: (item, text) => [
25
+ text.name ?? item.internal_name, text.alternate_name, text.description, item.country_id, ...(item.tags ?? []),
26
+ ],
27
+ name: (item, text) => text.name ?? item.internal_name,
28
+ location: (item, text) => [text.location, item.country_id],
29
+ provenance: (item, text) => text.provenance,
30
+ patron: (item, text) => text.patrons ?? text.initial_owner,
31
+ artist: (item, text) => [...(item.artist_names ?? []), text.architects],
32
+ material: (item, text) => text.type,
33
+ // The catch-all across the descriptive fields the others leave out.
34
+ other: (item, text) => [
35
+ text.description, text.method_for_datation, text.method_for_provenance, text.obtention,
36
+ text.bibliography, text.workshop, text.scriber, text.binding_desc, text.history,
37
+ ],
38
+ }
39
+ if (dynastyLabel) fields.dynasty = (item) => (item.dynasty_ids ?? []).map(dynastyLabel)
40
+ return fields
41
+ }
42
+
43
+ // Every field of the form, in legacy's order, with its entry name.
44
+ const FIELD_OPTIONS = [
45
+ { key: 'keyword', label: 'catalogue.field.keywords' },
46
+ { key: 'name', label: 'sheet.field.name' },
47
+ { key: 'location', label: 'sheet.field.location' },
48
+ { key: 'provenance', label: 'sheet.field.provenance' },
49
+ { key: 'dynasty', label: 'catalogue.facet.periodDynasty' },
50
+ { key: 'patron', label: 'catalogue.field.patron' },
51
+ { key: 'artist', label: 'catalogue.field.artist' },
52
+ { key: 'material', label: 'catalogue.field.material' },
53
+ { key: 'other', label: 'catalogue.field.other' },
54
+ ]
55
+
56
+ /**
57
+ * The options of the form's field select — `[{ key, label }]`, `label` an
58
+ * entry name, in legacy's order — for the fields `fields` carries: what
59
+ * `SearchFormView`'s `fields` takes.
60
+ */
61
+ export function searchFieldOptions(fields) {
62
+ return FIELD_OPTIONS.filter((option) => option.key in fields)
63
+ }
64
+
65
+ /** One field's label, resolved: `key` is what the query string carries. */
66
+ export function searchFieldLabel(key, t) {
67
+ switch (key) {
68
+ case 'keyword': return t('catalogue.field.keywords')
69
+ case 'name': return t('sheet.field.name')
70
+ case 'location': return t('sheet.field.location')
71
+ case 'provenance': return t('sheet.field.provenance')
72
+ case 'dynasty': return t('catalogue.facet.periodDynasty')
73
+ case 'patron': return t('catalogue.field.patron')
74
+ case 'artist': return t('catalogue.field.artist')
75
+ case 'material': return t('catalogue.field.material')
76
+ case 'other': return t('catalogue.field.other')
77
+ default: return key
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The same options resolved, `[{ value, label }]`, for a select a site draws
83
+ * itself — the results page's refine row.
84
+ */
85
+ export function useSearchFieldOptions(fields) {
86
+ const { t } = useI18n()
87
+ return computed(() => searchFieldOptions(fields).map((option) => ({ value: option.key, label: searchFieldLabel(option.key, t) })))
88
+ }
89
+
90
+ /**
91
+ * The keyword rows the query string carries: `q`/`field` for the first
92
+ * (`SearchFormView`'s own, unnumbered), `qN`/`fieldN`/`opN` for the next —
93
+ * three from the entrance, a fourth for the results page's refine row.
94
+ * A row with no field searches `keyword`; with no operator, AND.
95
+ */
96
+ export function searchRows(filters, count = 4) {
97
+ const rows = []
98
+ for (let n = 1; n <= count; n++) {
99
+ rows.push({
100
+ keyword: n === 1 ? filters.q : filters[`q${n}`],
101
+ field: (n === 1 ? filters.field : filters[`field${n}`]) || 'keyword',
102
+ cond: n === 1 ? 'AND' : filters[`op${n}`] || 'AND',
103
+ })
104
+ }
105
+ return rows
106
+ }
107
+
108
+ /** The query keys `searchRows` reads, for a results spec's `keys`. */
109
+ export function searchRowKeys(count = 4) {
110
+ const keys = ['q', 'field']
111
+ for (let n = 2; n <= count; n++) keys.push(`q${n}`, `field${n}`, `op${n}`)
112
+ return keys
113
+ }
114
+
115
+ /**
116
+ * What was searched, spelled out: `Name: "bowl"`, `AND Location: "Cairo"`,
117
+ * the years, the search language, then the site's own `extras` —
118
+ * `(filters, t) => string` each, an empty string for nothing. Legacy's
119
+ * recap line, never just a count, so a visitor who scrolls past the form
120
+ * still reads what for.
121
+ */
122
+ export function searchedFor(filters, t, { rows = 4, extras = [] } = {}) {
123
+ const parts = searchRows(filters, rows)
124
+ .filter((row) => row.keyword)
125
+ .map((row, i) => `${i > 0 ? `${row.cond} ` : ''}${searchFieldLabel(row.field, t)}: "${row.keyword}"`)
126
+ if (filters.from) parts.push(`${t('catalogue.filter.from')} ${filters.from}`)
127
+ if (filters.to) parts.push(`${t('catalogue.filter.to')} ${filters.to}`)
128
+ if (filters.lang) parts.push(`${t('catalogue.search.language')}: ${String(filters.lang).toUpperCase()}`)
129
+ for (const extra of extras) {
130
+ const part = extra(filters, t)
131
+ if (part) parts.push(part)
132
+ }
133
+ return parts
134
+ }
135
+
136
+ /**
137
+ * The results page's summary: what was searched (`searchedFor`), or "all
138
+ * items" for an empty query, then the count. `options` is `searchedFor`'s.
139
+ */
140
+ export function searchSummary({ filters, t, pageInfo }, options = {}) {
141
+ const parts = searchedFor(filters, t, options)
142
+ return [
143
+ { label: t('catalogue.search.summary'), value: parts.length ? parts.join(' · ') : t('catalogue.results.allItems') },
144
+ { label: t('catalogue.results.itemsFound'), count: pageInfo.total },
145
+ ]
146
+ }
147
+
148
+ /**
149
+ * The index behind the results page: one long-lived `useKeywordIndex` over
150
+ * `entity` (`fields` grammar, legacy's `rank: 'hits'` order, the glossary
151
+ * and country expansions of `database_results.php`), its search language
152
+ * kept in step with the query's `lang`. `narrow(list, filters)` is a results
153
+ * spec's `narrow`: the matches in rank order, kept to what `list` (the
154
+ * records the spec's `scope` let through) carries. Pass `sort: false` to the
155
+ * spec, so the rank order stands.
156
+ */
157
+ export function useFieldSearch({
158
+ fields,
159
+ entity = 'items',
160
+ rows = 4,
161
+ rank = 'hits',
162
+ expand = combineExpansions(glossaryExpansion(), countryExpansion()),
163
+ } = {}) {
164
+ const language = ref('')
165
+ const index = useKeywordIndex(entity, { grammar: 'fields', fields, language, rank, expand })
166
+
167
+ function narrow(list, filters) {
168
+ language.value = filters.lang || ''
169
+ const matches = index.search(searchRows(filters, rows))
170
+ if (!list) return matches
171
+ const allowed = new Set(list.map((record) => record.id))
172
+ return matches.filter((record) => allowed.has(record.id))
173
+ }
174
+
175
+ return { narrow, index }
176
+ }
@@ -108,13 +108,16 @@ function seeded(seed) {
108
108
  * among those with an image when `withImage` is set (the default), among
109
109
  * all when none has one. Null until the entity is loaded. The pick is made
110
110
  * once per visit and again only when the records change; `seed` makes it
111
- * deterministic.
111
+ * deterministic. `filter(record)` narrows the candidates first — the site's
112
+ * own visible rule, since this reads the raw entity (Sharing History's
113
+ * hidden illustrations, `display_status: 'N'`, must never be picked).
112
114
  */
113
- export function useFeaturedRecord(entity, { withImage = true, seed, images = 'images' } = {}) {
115
+ export function useFeaturedRecord(entity, { withImage = true, seed, images = 'images', filter } = {}) {
114
116
  const records = entityRef(entity)
115
117
  const random = seed == null ? Math.random : seeded(seed)
116
118
  return computed(() => {
117
- const all = toValue(records) ?? []
119
+ const loaded = toValue(records)
120
+ const all = filter ? (loaded ?? []).filter(filter) : loaded ?? []
118
121
  if (all.length === 0) return null
119
122
  const pool = withImage ? all.filter((record) => (record?.[images]?.length ?? 0) > 0) : all
120
123
  const candidates = pool.length > 0 ? pool : all
@@ -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'
package/src/index.js CHANGED
@@ -1,6 +1,13 @@
1
1
  export { createViewer } from './createViewer.js'
2
2
  export { createStandardViewer } from './createStandardViewer.js'
3
3
  export { useCatalogueData } from './composables/useCatalogueData.js'
4
+ export {
5
+ CATALOGUE_DATE_MODE, CATALOGUE_PAGE_SIZE, objectsAndMonumentsSummary, useCatalogue,
6
+ } from './catalogue/catalogue.js'
7
+ export {
8
+ searchFieldLabel, searchFieldOptions, searchFields, searchRowKeys, searchRows, searchSummary, searchedFor,
9
+ useFieldSearch, useSearchFieldOptions,
10
+ } from './catalogue/fieldSearch.js'
4
11
  export { useDataPackage } from './composables/useDataPackage.js'
5
12
  export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'
6
13
  export { resolveRecordLanguage, useRecordLanguage } from './composables/useRecordLanguage.js'
@@ -118,14 +118,10 @@ const byDisplayOrder = (a, b) => (a?.display_order ?? 0) - (b?.display_order ??
118
118
 
119
119
  /**
120
120
  * The contact persons in legacy order (person 1 first), keeping only those
121
- * with a name or a title — legacy printed a person block only then. Reads
122
- * `contact_persons`; a package built before that list existed carries
123
- * `contact_person_1`/`_2` instead (inventory-app#2007 removes them).
121
+ * with a name or a title — legacy printed a person block only then.
124
122
  */
125
123
  function contactPersons(partner) {
126
- const list = Array.isArray(partner?.contact_persons)
127
- ? partner.contact_persons
128
- : [partner?.contact_person_1, partner?.contact_person_2]
124
+ const list = Array.isArray(partner?.contact_persons) ? partner.contact_persons : []
129
125
  return list
130
126
  .filter((person) => person && (person.name || person.title))
131
127
  .map((person) => ({