@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.
- package/README.md +111 -2
- package/package.json +3 -2
- package/src/createStandardViewer.js +61 -0
- package/src/dxa/exhibition/useExhibitionCollection.js +176 -0
- package/src/dxa/exhibition/useExhibitionData.js +246 -0
- package/src/dxa/exhibition/useExhibitionPartner.js +62 -0
- package/src/dxa/exhibition/useExhibitionSheet.js +126 -0
- package/src/dxa/exhibition/useExhibitionTimeline.js +310 -0
- package/src/dxa/gallery/useGalleryCollection.js +175 -0
- package/src/dxa/gallery/useGalleryData.js +143 -0
- package/src/dxa/gallery/useGalleryPartner.js +77 -0
- package/src/dxa/gallery/useGallerySheet.js +118 -0
- package/src/dxa/gallery/useGalleryTimeline.js +226 -0
- package/src/dxa/index.js +34 -0
- package/src/dxa/shared.js +53 -0
- package/src/index.js +1 -0
|
@@ -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
|
+
}
|
package/src/dxa/index.js
ADDED
|
@@ -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'
|