@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,143 @@
1
+ import { computed } from 'vue'
2
+ import { mediaUrl } from '../../siteConfig.js'
3
+ import { useCatalogueData } from '../../composables/useCatalogueData.js'
4
+ import { useDataPackage } from '../../composables/useDataPackage.js'
5
+ import { itemFromUidPath as legacyItemFromUidPath, partnerFromKey as legacyPartnerFromKey } from '../../legacy/index.js'
6
+
7
+ // The data layer every DXA gallery site (carpets, amulets, …) wrote for
8
+ // itself: the entities a gallery reads, the lookup maps over them, its
9
+ // routes, its legacy URL mappings and its chrome. Promoted from the
10
+ // byte-identical copies the two live galleries carried (epic #1730) — what a
11
+ // future gallery needs that is genuinely its own belongs in `config`, not in
12
+ // a fork of this file.
13
+
14
+ /** Every entity a gallery page reads English labels or fallbacks for. */
15
+ const DEFAULT_EAGER = ['items', 'partners', 'countries', 'glossary', 'dynasties', 'timeline_events']
16
+
17
+ /**
18
+ * The gallery's data layer, bound to the installed data package.
19
+ *
20
+ * `config.eager` overrides the entities `loadEnglish` loads eagerly
21
+ * (default: {@link DEFAULT_EAGER}) — a future gallery whose package carries
22
+ * extra entities of its own passes a longer list.
23
+ */
24
+ export function useGalleryData(config = {}) {
25
+ const dataPackage = useDataPackage()
26
+ const manifest = dataPackage.manifest
27
+
28
+ const catalogue = useCatalogueData({
29
+ eager: config.eager ?? DEFAULT_EAGER,
30
+ })
31
+
32
+ const {
33
+ tr, md, mdInline, mdStrip, loadEnglish, labelOf,
34
+ availableLanguages, loadTranslations, translations,
35
+ } = catalogue
36
+
37
+ // English is the base language of every catalogue in the platform: every
38
+ // list, label and fallback reads it.
39
+ const defaultLang = 'en'
40
+
41
+ // ── Records ────────────────────────────────────────────────────────────
42
+ // Language-independent; every human-readable string lives under translations/.
43
+
44
+ const gallery = catalogue.entity('gallery')
45
+ const items = catalogue.entity('items')
46
+ const tags = catalogue.entity('tags')
47
+ const partners = catalogue.entity('partners')
48
+ const countries = catalogue.entity('countries')
49
+ const languages = catalogue.entity('languages')
50
+ const dynasties = catalogue.entity('dynasties')
51
+ const glossary = catalogue.entity('glossary')
52
+ const timelines = catalogue.entity('timelines')
53
+ const timelineEvents = catalogue.entity('timeline_events')
54
+
55
+ // ── Lookup maps ────────────────────────────────────────────────────────
56
+
57
+ const itemById = catalogue.index('items')
58
+ const partnerById = catalogue.index('partners')
59
+ const countryById = catalogue.index('countries')
60
+ const tagById = catalogue.index('tags')
61
+ const dynastyById = catalogue.index('dynasties')
62
+ const timelineById = catalogue.index('timelines')
63
+ const languageByCode = catalogue.index('languages', 'code')
64
+
65
+ // ── Routes ─────────────────────────────────────────────────────────────
66
+ //
67
+ // The canonical routes carry the package id; the language never travels
68
+ // in the path. The legacy shapes (the dbUid path of an item sheet, the
69
+ // country and legacy id of a partner) are redirect-only entries in the
70
+ // site's own `dataset.config.js`, resolved through the two wrappers below.
71
+
72
+ function itemRoute(item) {
73
+ return { name: 'item', params: { id: item.id } }
74
+ }
75
+
76
+ function partnerRoute(partner) {
77
+ return { name: 'partner', params: { id: partner.id } }
78
+ }
79
+
80
+ function partnerObjectsRoute(partner, page = 1) {
81
+ return { name: 'partner-objects', params: { id: partner.id }, query: page > 1 ? { page } : {} }
82
+ }
83
+
84
+ // Legacy dbUid ⇄ item, and a partner's country/legacy id ⇄ partner: the
85
+ // `legacy` entry point decodes `backward_compatibility` the way every DXA
86
+ // site does, pure, over plain lists; these two wrappers bind it to this
87
+ // gallery's own `items`/`partners`/`countries` refs, so the site's legacy
88
+ // routes keep calling them with the arguments they already carry.
89
+ function itemFromUidPath(path) {
90
+ return legacyItemFromUidPath(items.value ?? [], path)
91
+ }
92
+
93
+ function partnerFromKey(countryCode, legacyId) {
94
+ return legacyPartnerFromKey(partners.value ?? [], countries.value ?? [], countryCode, legacyId)
95
+ }
96
+
97
+ // ── Chrome images ──────────────────────────────────────────────────────
98
+ //
99
+ // `image_path`, `banner_image_path` and `homepage_image_path` were never
100
+ // imported into inventory storage: the package ships the legacy path and
101
+ // the address is built from the host the site's `dataset.config.js`
102
+ // declares under `media`.
103
+
104
+ function chromeImage(path, size = 'hi_res') {
105
+ return mediaUrl(path, size)
106
+ }
107
+
108
+ // ── Sibling galleries ──────────────────────────────────────────────────
109
+ //
110
+ // Decision Q3: these are reference objects, not resolved links. The
111
+ // exporter records identity plus whatever the import carried; where a
112
+ // `legacy_host` came across we can link to it, and where it did not the
113
+ // entry still renders — it just does not become an anchor.
114
+
115
+ const siblingGalleries = computed(() =>
116
+ (gallery.value?.sibling_galleries ?? []).filter((g) => !g.hidden),
117
+ )
118
+
119
+ function siblingUrl(sibling) {
120
+ return sibling?.legacy_host || null
121
+ }
122
+
123
+ /** Four at random, reshuffled per visit. */
124
+ function pickSiblings(count = 4) {
125
+ const pool = [...siblingGalleries.value]
126
+ for (let i = pool.length - 1; i > 0; i--) {
127
+ const j = Math.floor(Math.random() * (i + 1))
128
+ ;[pool[i], pool[j]] = [pool[j], pool[i]]
129
+ }
130
+ return pool.slice(0, count)
131
+ }
132
+
133
+ return {
134
+ manifest,
135
+ defaultLang,
136
+ tr, md, mdInline, mdStrip, loadEnglish, labelOf,
137
+ availableLanguages, loadTranslations, translations,
138
+ gallery, items, tags, partners, countries, languages, dynasties, glossary, timelines, timelineEvents,
139
+ itemById, partnerById, countryById, tagById, dynastyById, timelineById, languageByCode,
140
+ itemRoute, partnerRoute, partnerObjectsRoute, itemFromUidPath, partnerFromKey,
141
+ chromeImage, siblingGalleries, siblingUrl, pickSiblings,
142
+ }
143
+ }
@@ -0,0 +1,77 @@
1
+ import { PAGE_SIZE } from '../shared.js'
2
+
3
+ // The partner pages, as specs: what viewer-layout's `PartnerListView`,
4
+ // `RecordView` and `CatalogueResultsView` render on `/partners`,
5
+ // `/partner/:id` and `/partner/:id/objects`. The grouping, the A–Z toggle,
6
+ // the record's language and the pagination are the platform's; what is
7
+ // declared here is only what is a gallery's: DXA's own list shape (no
8
+ // tiers), the country label, and the object grid's tile.
9
+
10
+ /**
11
+ * The gallery's partner specs, over its data layer's `data` and the
12
+ * `PAGE_SIZE`/`tile` its {@link import('./useGalleryCollection.js').useGalleryCollection}
13
+ * already declared.
14
+ *
15
+ * Reads `data.labelOf`. `config` carries nothing today.
16
+ */
17
+ export function useGalleryPartner(data, collection, config = {}) {
18
+ void config
19
+ const { labelOf } = data
20
+ const { tile } = collection
21
+
22
+ // ── The list ───────────────────────────────────────────────────────────
23
+ //
24
+ // DXA's own shape, per the package README: no main/associated tiers
25
+ // (every partner is a flat row), grouped by country, with the A–Z / Z–A
26
+ // toggle legacy showed. `route` is a string, so the platform builds each
27
+ // row's link itself; the "View objects" link and the "no objects" line
28
+ // are the site's own `#row` concern in Partners.vue — decision MWNF-384:
29
+ // a partner created under a gallery's own project is listed even when it
30
+ // holds no member item, and only that link is withheld.
31
+ const partnerList = {
32
+ group: { tier: false, order: 'country' },
33
+ variant: 'open',
34
+ orderToggle: true,
35
+ route: 'partner',
36
+ label: (countryId) => labelOf('countries', countryId),
37
+ }
38
+
39
+ // ── The profile ────────────────────────────────────────────────────────
40
+ //
41
+ // Legacy's partner page is a tab strip (About/Contact/Logo/homepage),
42
+ // never a table of labelled fields, so `fields` stays empty —
43
+ // PartnerProfile.vue fills `header` and `before-sheet` with the tabs
44
+ // instead — and the sections a sheet carries by default (credits,
45
+ // citation, related records) are switched off: legacy printed none of
46
+ // them on this page.
47
+ const partnerSheet = {
48
+ entity: 'partners',
49
+ fields: [],
50
+ credits: [],
51
+ citation: false,
52
+ related: false,
53
+ route: 'partner',
54
+ }
55
+
56
+ // ── The objects page ──────────────────────────────────────────────────
57
+ //
58
+ // The member items one partner holds, on the same grid the collection
59
+ // results and the timeline gallery use. `scope` is built per-page in the
60
+ // site's own PartnerObjects.vue, over the route's own id —
61
+ // `CatalogueResultsView` takes no record id of its own, unlike
62
+ // `RecordView`.
63
+ const partnerObjects = {
64
+ entity: 'items',
65
+ sort: { undated: 'first' },
66
+ pageSize: PAGE_SIZE,
67
+ variant: 'grid',
68
+ recordRoute: 'item',
69
+ actionLabel: 'gallery.action.seeDatabaseEntry',
70
+ empty: 'gallery.partner.noObjects',
71
+ pagination: { jump: true },
72
+ record: (item, { t }) => tile(item, t),
73
+ summary: ({ pageInfo, t }) => [{ count: pageInfo.total, value: t('partner.item.objectsInSite') }],
74
+ }
75
+
76
+ return { partnerList, partnerSheet, partnerObjects }
77
+ }
@@ -0,0 +1,118 @@
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 a gallery'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 without a
8
+ // permalink, as legacy printed it. Every label is an entry name, written
9
+ // 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 a gallery does not offer) they fall back to English, which is
18
+ // 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 gallery'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.tr`,
30
+ * `data.translations`. `config` carries nothing today.
31
+ */
32
+ export function useGallerySheet(data, config = {}) {
33
+ void config
34
+ const { defaultLang, itemRoute, labelOf, mdInline, mdStrip, partnerById, 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: { permalink: false, heading: 'record.citation.ofThisPage' },
101
+ related: {
102
+ variant: 'grid',
103
+ heading: 'record.related.items',
104
+ actionLabel: 'gallery.action.seeDatabaseEntry',
105
+ record: ({ record: other, justification }) => ({
106
+ id: other.id,
107
+ image: other.images?.[0]?.url ?? '',
108
+ imageAlt: labelOf('items', other.id),
109
+ name: mdInline(tr('items', other.id, defaultLang).name ?? other.internal_name ?? ''),
110
+ meta: [labelOf('countries', other.country_id), justification ? mdStrip(justification) : ''].filter(Boolean),
111
+ to: itemRoute(other),
112
+ }),
113
+ },
114
+ route: 'item',
115
+ }
116
+
117
+ return { itemSheet }
118
+ }
@@ -0,0 +1,226 @@
1
+ import { computed } from 'vue'
2
+ import { dateRange, eraLabel, roundOutward, yearBucketsFromRange } from '../../catalogue/dates.js'
3
+ import { useTimelineEvents } from '../../catalogue/timeline.js'
4
+ import { PAGE_SIZE } from '../shared.js'
5
+
6
+ export { eraLabel, roundOutward }
7
+
8
+ /**
9
+ * The gallery's timeline spec, over its data layer's `data` and the
10
+ * `PAGE_SIZE`/`tile` its {@link import('./useGalleryCollection.js').useGalleryCollection}
11
+ * already declared.
12
+ *
13
+ * Reads `data.timelines`, `data.countries`, `data.countryById`,
14
+ * `data.labelOf`, `data.items`, `data.tr`, `data.loadTranslations`,
15
+ * `data.defaultLang`. `config` carries nothing today.
16
+ */
17
+ export function useGalleryTimeline(data, collection, config = {}) {
18
+ void config
19
+ const { timelines, countries, countryById, labelOf, items, tr, loadTranslations, defaultLang } = data
20
+ const { tile } = collection
21
+
22
+ // `TimelineResultsView` takes `tr` as a plain callback rather than an
23
+ // entity name, unlike `CatalogueResultsView`/`RecordView`, which load
24
+ // their own entity's English translations when they mount. Nothing does
25
+ // that for this one, so it is done here, once, the moment this composable
26
+ // is created.
27
+ loadTranslations('timeline_events', defaultLang)
28
+
29
+ // The merge itself — the worldwide country chronology, `mwnf3.hcr`
30
+ // combined with Sharing History's exhibition-2 rows the way legacy's
31
+ // `/v2/events` served it — is viewer-core's `useTimelineEvents`. What
32
+ // stays here is what is genuinely a gallery's own: the DXA legacy
33
+ // 2-letter country code (a gallery's Timeline URLs are keyed on it, not
34
+ // on the inventory id) and the two specs the composed views render from.
35
+ //
36
+ // Names and legacy codes both come from countries.json. The exporter
37
+ // scopes that file to "member item countries ∪ their holders' countries ∪
38
+ // the global timeline's countries", so it covers every timeline country —
39
+ // the `Intl.DisplayNames` fallback below is therefore dead code for them,
40
+ // and is kept only so a regressed package degrades to a rendered ISO code
41
+ // rather than a raw id. The `code` countries.json ships is the country's
42
+ // `backward_compatibility`, which is exactly the code legacy's own
43
+ // timeline URLs used, including the ones that are not ISO 3166-1
44
+ // alpha-2 (`uk`, `pa`, `qt`, `rm`, `sb`, `ua`, …). Those are the reason
45
+ // the fallback must stay unreachable rather than merely rare: read as
46
+ // ISO, `ua` is Ukraine and `sb` is the Solomon Islands, where legacy
47
+ // means the UAE and Serbia. Only countries.json can name them correctly.
48
+ const regionNames = (() => {
49
+ try {
50
+ return new Intl.DisplayNames(['en'], { type: 'region' })
51
+ } catch {
52
+ return null
53
+ }
54
+ })()
55
+
56
+ // Two legacy codes are not ISO 3166-1 alpha-2.
57
+ const LEGACY_TO_ISO = { uk: 'GB', pa: 'PS' }
58
+
59
+ // `TimelineResultsView`'s own country control (`countries` from
60
+ // viewer-core) writes the inventory id, not the legacy code
61
+ // (`?country=dza`, not `?country=dz`); `countryIdForCode` used to answer
62
+ // nothing for one, silently matching every event. Built once, next to
63
+ // `LEGACY_TO_ISO`, rather than inside the function it is read from.
64
+ const countryIdSet = computed(() => new Set(countries.value.map((c) => c.id)))
65
+
66
+ // A lookup, not a parse. The fallback exists only for the
67
+ // regressed-package case described above, and it must agree with
68
+ // GLOBAL_TIMELINE_LIKE_PATTERNS in the exporter's timeline exporter: the
69
+ // country sits after the literal `country` segment in BOTH keyspaces
70
+ // (`mwnf3:hcr:country:<cc>` and
71
+ // `mwnf3_sharing_history:sh_hcr:country:<cc>:exhibition:2`), and it is
72
+ // the last segment in only the first — taking the last one yields `2` on
73
+ // the second.
74
+ function legacyCodeOf(timeline) {
75
+ const fromPackage = countryById.value.get(timeline.country_id)?.code
76
+ if (fromPackage) return fromPackage
77
+ const parts = (timeline.backward_compatibility ?? '').split(':')
78
+ const at = parts.indexOf('country')
79
+ return (at >= 0 ? parts[at + 1] : null) || timeline.country_id
80
+ }
81
+
82
+ function nameFor(timeline) {
83
+ const fromPackage = countries.value.some((c) => c.id === timeline.country_id)
84
+ ? labelOf('countries', timeline.country_id)
85
+ : null
86
+ if (fromPackage) return fromPackage
87
+ const legacy = legacyCodeOf(timeline)
88
+ const iso = LEGACY_TO_ISO[legacy] ?? String(legacy).toUpperCase()
89
+ try {
90
+ return regionNames?.of(iso) ?? iso
91
+ } catch {
92
+ return iso
93
+ }
94
+ }
95
+
96
+ /** Display name for a country the chronology carries, keyed by inventory id. */
97
+ function countryLabel(countryId) {
98
+ if (countries.value.some((c) => c.id === countryId)) return labelOf('countries', countryId)
99
+ const timeline = timelines.value.find((t) => t.country_id === countryId)
100
+ return timeline ? nameFor(timeline) : countryId
101
+ }
102
+
103
+ /**
104
+ * Legacy 2-letter code → the inventory country id the events are keyed
105
+ * by. A country served by both chronologies has two rows carrying the
106
+ * same code and the same `country_id`, so either row answers.
107
+ */
108
+ function countryIdForCode(code) {
109
+ if (!code || code === 'all') return null
110
+ // Already an id (the composed view's own control writes one): pass it
111
+ // through unchanged, before the legacy-code lookups below get a chance
112
+ // to find nothing and silently match every event.
113
+ if (countryIdSet.value.has(code)) return code
114
+ const timeline = timelines.value.find((t) => legacyCodeOf(t) === code)
115
+ if (timeline) return timeline.country_id
116
+ // Countries with no chronology still reach here from the collection
117
+ // page's "Timeline for this Search" link, which uses countries.json's
118
+ // own codes.
119
+ return countries.value.find((c) => c.code === code)?.id ?? null
120
+ }
121
+
122
+ /**
123
+ * The one merge engine every Timeline page on a gallery shares —
124
+ * worldwide, project-independent, which is why it works even though a
125
+ * gallery's own `has_country_timeline` flag is false, exactly as on the
126
+ * live site. `TimelineResultsView`'s two instances (entrance, results)
127
+ * each read the spec below and build their own; the item sheet's
128
+ * "Timeline for this item" widget is the one place that reads it
129
+ * directly, for a per-item country and range the composed view has no
130
+ * shape for.
131
+ */
132
+ const timelineEvents = useTimelineEvents({
133
+ scope: 'country',
134
+ countryLabel,
135
+ countryIdForCode,
136
+ tr: (id) => tr('timeline_events', id, defaultLang),
137
+ })
138
+
139
+ // ── The gallery join ──────────────────────────────────────────────────
140
+ //
141
+ // Legacy's timeline-gallery page joined events to member items by country
142
+ // and year range client-side; the package spec anticipated exactly this.
143
+ // Overlap, not containment: a period is a window on the chronology, and
144
+ // an object made across its edge belongs in it.
145
+
146
+ function inTimelineScope(item, countryCode) {
147
+ const countryId = countryIdForCode(countryCode)
148
+ return Number.isFinite(item.start_date) && (!countryId || item.country_id === countryId)
149
+ }
150
+
151
+ /** The member items in `filters.country`'s period — the "See gallery" count. */
152
+ function timelineGalleryItems({ country, begin, end } = {}) {
153
+ const list = (items.value ?? []).filter((i) => inTimelineScope(i, country))
154
+ return dateRange(list, { begin, end, mode: 'overlap' })
155
+ }
156
+
157
+ // ── The entrance and results page, as one spec ─────────────────────────
158
+ //
159
+ // What viewer-layout's `TimelineResultsView` renders on `/timeline`
160
+ // (entrance: true) and `/timeline-results` (entrance: false): the
161
+ // country and period controls, the events list, and the "See gallery"
162
+ // cross-link — shown whenever the chosen country and period actually
163
+ // contain member items, for every country including "all".
164
+
165
+ const timelineResults = {
166
+ scope: 'country',
167
+ countryLabel,
168
+ countryIdForCode,
169
+ tr: (id) => tr('timeline_events', id, defaultLang),
170
+ route: 'timeline-results',
171
+ controls: [
172
+ { key: 'country' },
173
+ { key: 'begin', options: (ctx) => yearBucketsFromRange(ctx.years.min, ctx.years.max, ctx.t) },
174
+ { key: 'end', options: (ctx) => yearBucketsFromRange(ctx.years.min, ctx.years.max, ctx.t) },
175
+ ],
176
+ gallery: {
177
+ route: 'timeline-gallery',
178
+ items: (ctx) => timelineGalleryItems(ctx.filters),
179
+ },
180
+ }
181
+
182
+ // ── The gallery page, as a spec ─────────────────────────────────────────
183
+ //
184
+ // What viewer-layout's `CatalogueResultsView` renders on
185
+ // `/timeline/gallery`: the same country-and-period join as the cross-link
186
+ // above, over the member items, undated last, nine tiles a page. No
187
+ // facets or controls: the country and period travel from the timeline
188
+ // results page, not from this page's own form.
189
+
190
+ const timelineGallery = {
191
+ entity: 'items',
192
+ // Named explicitly: there are no facets here for the default key list
193
+ // to infer 'country' from, only the join's own scope function.
194
+ keys: ['country', 'begin', 'end'],
195
+ scope: (item, filters) => inTimelineScope(item, filters.country),
196
+ dates: { mode: 'overlap' },
197
+ sort: { undated: 'first' },
198
+ pageSize: PAGE_SIZE,
199
+ variant: 'grid',
200
+ recordRoute: 'item',
201
+ actionLabel: 'gallery.action.seeDatabaseEntry',
202
+ empty: 'gallery.results.noObjectsInPeriod',
203
+ pagination: { jump: true },
204
+
205
+ record: (item, { t }) => tile(item, t),
206
+
207
+ summary: ({ filters, pageInfo, t }) => {
208
+ const countryId = countryIdForCode(filters.country)
209
+ const begin = filters.begin ? Number(filters.begin) : null
210
+ const end = filters.end ? Number(filters.end) : null
211
+ const parts = [{
212
+ label: t('timeline.results.galleryHeading'),
213
+ value: countryId ? labelOf('countries', countryId) : t('timeline.form.allCountries'),
214
+ }]
215
+ if (begin != null || end != null) {
216
+ const from = begin != null ? eraLabel(begin, t) : t('timeline.form.earliest')
217
+ const to = end != null ? eraLabel(end, t) : t('timeline.form.latest')
218
+ parts.push({ value: `${from} ${t('timeline.form.to')} ${to}` })
219
+ }
220
+ parts.push({ count: pageInfo.total, value: t('catalogue.results.objects') })
221
+ return parts
222
+ },
223
+ }
224
+
225
+ return { countryLabel, countryIdForCode, timelineEvents, timelineGalleryItems, timelineResults, timelineGallery }
226
+ }
@@ -0,0 +1,34 @@
1
+ // The DXA gallery/exhibition composables (epic #1730): the data layer,
2
+ // catalogue spec, timeline spec, partner specs and item-sheet spec each of
3
+ // the two live DXA site pairs (carpets/amulets — a gallery; the-use-of-
4
+ // colours-in-art/water-in-islam — an exhibition) wrote for itself,
5
+ // byte-identical within each pair. Promoted here per family, never as one
6
+ // composable that papers over the two — the cross-family diff is real
7
+ // behaviour (see each file's own header), not just naming.
8
+ //
9
+ // A site composes its own module from these, passing the `data` object one
10
+ // family's `useGalleryData()`/`useExhibitionData()` call returns into the
11
+ // rest:
12
+ //
13
+ // import { useGalleryData, useGalleryCollection, useGalleryTimeline, useGalleryPartner, useGallerySheet } from '@museumwnf/viewer-core/dxa'
14
+ //
15
+ // const data = useGalleryData()
16
+ // const collection = useGalleryCollection(data)
17
+ // const timeline = useGalleryTimeline(data, collection)
18
+ // const partner = useGalleryPartner(data, collection)
19
+ // const sheet = useGallerySheet(data)
20
+
21
+ // Shared verbatim by both families — see shared.js.
22
+ export { DATE_MODE, FACET_CATEGORIES, FACET_LABEL_KEYS, PAGE_SIZE, useFacetLabels } from './shared.js'
23
+
24
+ export { useGalleryData } from './gallery/useGalleryData.js'
25
+ export { useGalleryCollection } from './gallery/useGalleryCollection.js'
26
+ export { useGalleryTimeline } from './gallery/useGalleryTimeline.js'
27
+ export { useGalleryPartner } from './gallery/useGalleryPartner.js'
28
+ export { useGallerySheet } from './gallery/useGallerySheet.js'
29
+
30
+ export { useExhibitionData } from './exhibition/useExhibitionData.js'
31
+ export { useExhibitionCollection } from './exhibition/useExhibitionCollection.js'
32
+ export { useExhibitionTimeline } from './exhibition/useExhibitionTimeline.js'
33
+ export { useExhibitionPartner } from './exhibition/useExhibitionPartner.js'
34
+ export { useExhibitionSheet } from './exhibition/useExhibitionSheet.js'
@@ -0,0 +1,53 @@
1
+ import { useI18n } from '../i18n/index.js'
2
+
3
+ // The five THG catalogue facets, shared verbatim by both DXA families (a
4
+ // gallery's and an exhibition's `useCollection.js` declared the exact same
5
+ // four constants and helper) — everything else about a family's catalogue
6
+ // spec (the facet *predicates*, the tile, the results spec) reads real
7
+ // per-family data and stays in `gallery/useGalleryCollection.js` /
8
+ // `exhibition/useExhibitionCollection.js`.
9
+
10
+ /** Nine tiles a page, as legacy's grids showed. */
11
+ export const PAGE_SIZE = 9
12
+
13
+ /**
14
+ * Decision D5: the DXA sites test containment — `na <= start_date` and
15
+ * `nz >= coalesce(end_date, start_date)` in `Objects.blade.php` — so an
16
+ * undated record is out the moment a bound is set.
17
+ */
18
+ export const DATE_MODE = 'contain'
19
+
20
+ // The five THG facet categories, in the order the legacy form shows them.
21
+ // `artist` has no dropdown in dxa-client, but the category exists in the
22
+ // data and the exporter ships it, so it renders whenever it has a value — a
23
+ // superset of legacy, never a different answer.
24
+ export const FACET_CATEGORIES = ['type', 'dynasty', 'subject', 'material', 'artist']
25
+
26
+ /**
27
+ * The heading each facet dropdown carries, as entry names. Written out once
28
+ * here so both the entrance's `SearchFormView` spec (which translates a
29
+ * facet's `label` itself) and the results page's own aside panel (which
30
+ * wants the translated string) read the same five names, and so
31
+ * `viewer-i18n-check` can see them.
32
+ */
33
+ export const FACET_LABEL_KEYS = {
34
+ type: 'catalogue.facet.type',
35
+ dynasty: 'catalogue.facet.periodDynasty',
36
+ subject: 'catalogue.facet.subject',
37
+ material: 'catalogue.facet.material',
38
+ artist: 'catalogue.facet.artist',
39
+ }
40
+
41
+ // Every name written out, not read off `FACET_LABEL_KEYS` through a
42
+ // variable: `viewer-i18n-check` only sees a name spelled out at the call
43
+ // site, so a loop over the map above would be invisible to it.
44
+ export function useFacetLabels() {
45
+ const { t } = useI18n()
46
+ return {
47
+ type: t('catalogue.facet.type'),
48
+ dynasty: t('catalogue.facet.periodDynasty'),
49
+ subject: t('catalogue.facet.subject'),
50
+ material: t('catalogue.facet.material'),
51
+ artist: t('catalogue.facet.artist'),
52
+ }
53
+ }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createViewer } from './createViewer.js'
2
+ export { createStandardViewer } from './createStandardViewer.js'
2
3
  export { useCatalogueData } from './composables/useCatalogueData.js'
3
4
  export { useDataPackage } from './composables/useDataPackage.js'
4
5
  export { byId, entityRef, loadEntities, useEntities } from './composables/useEntities.js'