@museumwnf/viewer-core 1.14.0 → 1.15.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.
Files changed (43) hide show
  1. package/LICENSE.md +44 -44
  2. package/README.md +658 -635
  3. package/package.json +50 -50
  4. package/src/AppRoot.vue +58 -58
  5. package/src/catalogue/dates.js +209 -209
  6. package/src/catalogue/facets.js +61 -61
  7. package/src/catalogue/keywordIndex.js +289 -289
  8. package/src/catalogue/listQuery.js +100 -100
  9. package/src/catalogue/pagination.js +57 -57
  10. package/src/catalogue/searchLanguage.js +32 -32
  11. package/src/catalogue/timeline.js +220 -220
  12. package/src/composables/useCatalogueData.js +141 -141
  13. package/src/composables/useDataPackage.js +98 -98
  14. package/src/composables/useEntities.js +100 -100
  15. package/src/composables/useRecordLanguage.js +71 -71
  16. package/src/conventions.js +252 -148
  17. package/src/createViewer.js +29 -29
  18. package/src/i18n/I18nText.vue +17 -17
  19. package/src/i18n/I18nTextInline.vue +17 -17
  20. package/src/i18n/index.js +88 -88
  21. package/src/i18n/language.js +127 -127
  22. package/src/i18n/markdown.js +265 -265
  23. package/src/index.js +51 -50
  24. package/src/injectionKeys.js +1 -1
  25. package/src/languages.js +49 -49
  26. package/src/legacy/index.js +58 -58
  27. package/src/record/collectionTree.js +307 -307
  28. package/src/record/derivations.js +154 -154
  29. package/src/record/partners.js +87 -87
  30. package/src/record/recordSheet.js +231 -231
  31. package/src/rights.js +53 -53
  32. package/src/router/index.js +138 -138
  33. package/src/siteConfig.js +37 -37
  34. package/src/styles/base.css +49 -49
  35. package/src/testing/checkOfferedLanguages.js +67 -67
  36. package/src/testing/index.js +3 -3
  37. package/src/testing/smoke.js +103 -103
  38. package/src/testing/viteConfig.js +72 -90
  39. package/src/views/DetailView.vue +53 -53
  40. package/src/views/HomeView.vue +20 -20
  41. package/src/views/ListView.vue +61 -61
  42. package/src/views/NotFoundView.vue +8 -8
  43. package/src/vite.js +8 -8
@@ -1,103 +1,103 @@
1
- import { createViewer } from '../createViewer.js'
2
-
3
- /**
4
- * Mount a website to a detached DOM node on a given hash, returning the app,
5
- * host, and router for test assertions. The router is ready and the app is
6
- * mounted before the promise resolves.
7
- *
8
- * `config` and `messages` are the same objects a website's main.js assembles.
9
- * `hash` defaults to the home page.
10
- */
11
- export async function mountSite(config, messages, hash = '#/') {
12
- window.location.hash = hash
13
- const app = createViewer({ ...config, messages })
14
- const host = document.createElement('div')
15
- document.body.appendChild(host)
16
- app.mount(host)
17
- const router = app.config.globalProperties.$router
18
- await router.isReady()
19
- return { app, host, router }
20
- }
21
-
22
- /**
23
- * Check that a configuration's routes are properly declared: every route has
24
- * a name, no route is a catch-all, and every legacy route is listed in
25
- * `legacyPaths`.
26
- *
27
- * Returns the list of problems found (empty if all is well). Routes without
28
- * names appear by index in the message.
29
- */
30
- export function checkRoutes(config, { names = [], legacyPaths = [] } = {}) {
31
- const problems = []
32
- const declaredNames = (config.extraViews ?? []).map((r) => r.name)
33
- const declaredLegacy = (config.legacyRoutes ?? []).map((r) => r.path)
34
-
35
- // Check every route has a name.
36
- for (let i = 0; i < (config.extraViews ?? []).length; i++) {
37
- if (!config.extraViews[i].name) {
38
- problems.push(`Route ${i} has no name.`)
39
- }
40
- }
41
-
42
- // Check no route is a catch-all.
43
- if ((config.extraViews ?? []).some((r) => r.path?.includes('pathMatch'))) {
44
- problems.push('A route is a catch-all (contains pathMatch); the catch-all belongs to the router.')
45
- }
46
-
47
- // Check expected names are present.
48
- for (const name of names) {
49
- if (!declaredNames.includes(name)) {
50
- problems.push(`Expected route "${name}" not found.`)
51
- }
52
- }
53
-
54
- // Check expected legacy paths are present.
55
- for (const path of legacyPaths) {
56
- if (!declaredLegacy.includes(path)) {
57
- problems.push(`Expected legacy path "${path}" not found.`)
58
- }
59
- }
60
-
61
- return problems
62
- }
63
-
64
- /**
65
- * Check that every route has a `meta.section` string. A route without one
66
- * leaves the menu unaware of what section the visitor is in.
67
- *
68
- * Returns the list of problems found (empty if all is well).
69
- */
70
- export function checkSectionMeta(config) {
71
- const problems = []
72
-
73
- for (const route of config.extraViews ?? []) {
74
- if (typeof route.meta?.section !== 'string' || !route.meta.section.trim()) {
75
- problems.push(`Route "${route.name}" has no section metadata.`)
76
- }
77
- }
78
-
79
- return problems
80
- }
81
-
82
- /**
83
- * Check that the texts in a mounted host include entries from the given
84
- * namespaces. Matches each namespace against the rendered text with a
85
- * case-insensitive regex like `/\b(ns1|ns2)\.[a-z]/i`.
86
- *
87
- * Returns the list of text keys matched (empty if none found). A missing
88
- * key renders as its bare name, e.g. `"catalogue.era.ad"`, which the regex
89
- * would match.
90
- */
91
- export function checkTextsRendered(host, { namespaces = [] } = {}) {
92
- if (!namespaces.length) return []
93
-
94
- const text = host.textContent
95
- const pattern = new RegExp(`\\b(${namespaces.join('|')})\\.[a-z]`, 'gi')
96
- const found = new Set()
97
-
98
- for (const match of text.matchAll(pattern)) {
99
- found.add(match[1])
100
- }
101
-
102
- return Array.from(found)
103
- }
1
+ import { createViewer } from '../createViewer.js'
2
+
3
+ /**
4
+ * Mount a website to a detached DOM node on a given hash, returning the app,
5
+ * host, and router for test assertions. The router is ready and the app is
6
+ * mounted before the promise resolves.
7
+ *
8
+ * `config` and `messages` are the same objects a website's main.js assembles.
9
+ * `hash` defaults to the home page.
10
+ */
11
+ export async function mountSite(config, messages, hash = '#/') {
12
+ window.location.hash = hash
13
+ const app = createViewer({ ...config, messages })
14
+ const host = document.createElement('div')
15
+ document.body.appendChild(host)
16
+ app.mount(host)
17
+ const router = app.config.globalProperties.$router
18
+ await router.isReady()
19
+ return { app, host, router }
20
+ }
21
+
22
+ /**
23
+ * Check that a configuration's routes are properly declared: every route has
24
+ * a name, no route is a catch-all, and every legacy route is listed in
25
+ * `legacyPaths`.
26
+ *
27
+ * Returns the list of problems found (empty if all is well). Routes without
28
+ * names appear by index in the message.
29
+ */
30
+ export function checkRoutes(config, { names = [], legacyPaths = [] } = {}) {
31
+ const problems = []
32
+ const declaredNames = (config.extraViews ?? []).map((r) => r.name)
33
+ const declaredLegacy = (config.legacyRoutes ?? []).map((r) => r.path)
34
+
35
+ // Check every route has a name.
36
+ for (let i = 0; i < (config.extraViews ?? []).length; i++) {
37
+ if (!config.extraViews[i].name) {
38
+ problems.push(`Route ${i} has no name.`)
39
+ }
40
+ }
41
+
42
+ // Check no route is a catch-all.
43
+ if ((config.extraViews ?? []).some((r) => r.path?.includes('pathMatch'))) {
44
+ problems.push('A route is a catch-all (contains pathMatch); the catch-all belongs to the router.')
45
+ }
46
+
47
+ // Check expected names are present.
48
+ for (const name of names) {
49
+ if (!declaredNames.includes(name)) {
50
+ problems.push(`Expected route "${name}" not found.`)
51
+ }
52
+ }
53
+
54
+ // Check expected legacy paths are present.
55
+ for (const path of legacyPaths) {
56
+ if (!declaredLegacy.includes(path)) {
57
+ problems.push(`Expected legacy path "${path}" not found.`)
58
+ }
59
+ }
60
+
61
+ return problems
62
+ }
63
+
64
+ /**
65
+ * Check that every route has a `meta.section` string. A route without one
66
+ * leaves the menu unaware of what section the visitor is in.
67
+ *
68
+ * Returns the list of problems found (empty if all is well).
69
+ */
70
+ export function checkSectionMeta(config) {
71
+ const problems = []
72
+
73
+ for (const route of config.extraViews ?? []) {
74
+ if (typeof route.meta?.section !== 'string' || !route.meta.section.trim()) {
75
+ problems.push(`Route "${route.name}" has no section metadata.`)
76
+ }
77
+ }
78
+
79
+ return problems
80
+ }
81
+
82
+ /**
83
+ * Check that the texts in a mounted host include entries from the given
84
+ * namespaces. Matches each namespace against the rendered text with a
85
+ * case-insensitive regex like `/\b(ns1|ns2)\.[a-z]/i`.
86
+ *
87
+ * Returns the list of text keys matched (empty if none found). A missing
88
+ * key renders as its bare name, e.g. `"catalogue.era.ad"`, which the regex
89
+ * would match.
90
+ */
91
+ export function checkTextsRendered(host, { namespaces = [] } = {}) {
92
+ if (!namespaces.length) return []
93
+
94
+ const text = host.textContent
95
+ const pattern = new RegExp(`\\b(${namespaces.join('|')})\\.[a-z]`, 'gi')
96
+ const found = new Set()
97
+
98
+ for (const match of text.matchAll(pattern)) {
99
+ found.add(match[1])
100
+ }
101
+
102
+ return Array.from(found)
103
+ }
@@ -1,90 +1,72 @@
1
- import path from 'node:path'
2
-
3
- /**
4
- * The shared Vite configuration for viewer websites, returning a config object
5
- * ready to pass to `defineConfig()`. The seven websites duplicate this shape:
6
- * the `@inventory-data` alias to the data package, `optimizeDeps` to inline
7
- * the viewer packages, `testTimeout`, and the test environment.
8
- *
9
- * `dataPackage` is the npm package name, e.g. `'@museumwnf/islamicart-data'`.
10
- * `inline` is an optional array of extra packages to inline in tests, beyond
11
- * the required `'@museumwnf/viewer-core'` and `'@museumwnf/viewer-layout'`
12
- * (and, transitionally, their pre-#1722 `'@metanull/*'` spellings — see the
13
- * comment on `test.server.deps.inline` below).
14
- * `plugins` is an optional array of Vite plugins (e.g. `@vitejs/plugin-vue`),
15
- * which the site must supply because this package does not depend on them.
16
- * `root` is an optional project root path (default `process.cwd()`); use it when
17
- * loading the config from a context other than the project directory.
18
- */
19
- export function defineViewerConfig({
20
- dataPackage,
21
- inline = [],
22
- plugins = [],
23
- root = process.cwd(),
24
- } = {}) {
25
- return {
26
- plugins,
27
- resolve: {
28
- alias: {
29
- // viewer-core reads every JSON of the data package through this alias.
30
- // Resolve against the caller's project root, not this file's installed
31
- // location: import.meta.url is the package's own path inside node_modules,
32
- // not the site's project root where its node_modules actually exists.
33
- '@inventory-data': path.join(root, 'node_modules', dataPackage),
34
- },
35
- },
36
- optimizeDeps: {
37
- // viewer-core ships .vue source that esbuild pre-bundling cannot parse;
38
- // viewer-layout must not be pre-bundled either or its chunk gets a second
39
- // copy of the Vue runtime in dev (both packages share the app's vue).
40
- // The /i18n subpath is listed as well as the package: Vite pre-bundles a
41
- // subpath as its own entry, and a second copy of the text module would be
42
- // a second, empty set of texts for whatever imported it.
43
- //
44
- // Both the @museumwnf and @metanull spellings are listed: this list is
45
- // matched against the *site's own* import specifier, which still says
46
- // @metanull/* until every site completes its own move in
47
- // metanull/inventory-app#1722. Matching only the new name silently stops
48
- // excluding the package for every current consumer.
49
- exclude: [
50
- '@museumwnf/viewer-core',
51
- '@museumwnf/viewer-core/i18n',
52
- '@museumwnf/viewer-layout',
53
- '@metanull/viewer-core',
54
- '@metanull/viewer-core/i18n',
55
- '@metanull/viewer-layout',
56
- ],
57
- // The runtime deps reach the browser through those excluded packages, so
58
- // the dev-server dependency scan cannot discover them until the website's
59
- // own views import them directly. Without this list a late discovery
60
- // pre-bundles a second copy of Vue next to the raw one already loaded,
61
- // and the dev server crashes on boot. Listing them pre-bundles each
62
- // exactly once, and the excluded packages get the same copy.
63
- include: ['vue', 'vue-router'],
64
- },
65
- test: {
66
- environment: 'jsdom',
67
- testTimeout: 60000,
68
- server: {
69
- deps: {
70
- // viewer-core ships .vue source; Node cannot load it unless Vitest
71
- // processes the package instead of externalizing it, matched by the
72
- // specifier the *importing site* actually wrote. viewer-layout's
73
- // composed views import viewer-core, so the layout is processed too.
74
- // Both scopes are listed for the same transitional reason as
75
- // optimizeDeps.exclude above: sites still import @metanull/* until
76
- // metanull/inventory-app#1722, so matching only @museumwnf/* would
77
- // externalize this package for every current consumer, sending its
78
- // raw .vue files to Node's loader instead of Vitest's transform.
79
- inline: [
80
- '@museumwnf/viewer-core',
81
- '@museumwnf/viewer-layout',
82
- '@metanull/viewer-core',
83
- '@metanull/viewer-layout',
84
- ...inline,
85
- ],
86
- },
87
- },
88
- },
89
- }
90
- }
1
+ import path from 'node:path'
2
+
3
+ /**
4
+ * The shared Vite configuration for viewer websites, returning a config object
5
+ * ready to pass to `defineConfig()`. The seven websites duplicate this shape:
6
+ * the `@inventory-data` alias to the data package, `optimizeDeps` to inline
7
+ * the viewer packages, `testTimeout`, and the test environment.
8
+ *
9
+ * `dataPackage` is the npm package name, e.g. `'@museumwnf/islamicart-data'`.
10
+ * `inline` is an optional array of extra packages to inline in tests, beyond
11
+ * the required `'@museumwnf/viewer-core'` and `'@museumwnf/viewer-layout'`.
12
+ * `plugins` is an optional array of Vite plugins (e.g. `@vitejs/plugin-vue`),
13
+ * which the site must supply because this package does not depend on them.
14
+ * `root` is an optional project root path (default `process.cwd()`); use it when
15
+ * loading the config from a context other than the project directory.
16
+ */
17
+ export function defineViewerConfig({
18
+ dataPackage,
19
+ inline = [],
20
+ plugins = [],
21
+ root = process.cwd(),
22
+ } = {}) {
23
+ return {
24
+ plugins,
25
+ resolve: {
26
+ alias: {
27
+ // viewer-core reads every JSON of the data package through this alias.
28
+ // Resolve against the caller's project root, not this file's installed
29
+ // location: import.meta.url is the package's own path inside node_modules,
30
+ // not the site's project root where its node_modules actually exists.
31
+ '@inventory-data': path.join(root, 'node_modules', dataPackage),
32
+ },
33
+ },
34
+ optimizeDeps: {
35
+ // viewer-core ships .vue source that esbuild pre-bundling cannot parse;
36
+ // viewer-layout must not be pre-bundled either or its chunk gets a second
37
+ // copy of the Vue runtime in dev (both packages share the app's vue).
38
+ // The /i18n subpath is listed as well as the package: Vite pre-bundles a
39
+ // subpath as its own entry, and a second copy of the text module would be
40
+ // a second, empty set of texts for whatever imported it.
41
+ exclude: [
42
+ '@museumwnf/viewer-core',
43
+ '@museumwnf/viewer-core/i18n',
44
+ '@museumwnf/viewer-layout',
45
+ ],
46
+ // The runtime deps reach the browser through those excluded packages, so
47
+ // the dev-server dependency scan cannot discover them until the website's
48
+ // own views import them directly. Without this list a late discovery
49
+ // pre-bundles a second copy of Vue next to the raw one already loaded,
50
+ // and the dev server crashes on boot. Listing them pre-bundles each
51
+ // exactly once, and the excluded packages get the same copy.
52
+ include: ['vue', 'vue-router'],
53
+ },
54
+ test: {
55
+ environment: 'jsdom',
56
+ testTimeout: 60000,
57
+ server: {
58
+ deps: {
59
+ // viewer-core ships .vue source; Node cannot load it unless Vitest
60
+ // processes the package instead of externalizing it, matched by the
61
+ // specifier the *importing site* actually wrote. viewer-layout's
62
+ // composed views import viewer-core, so the layout is processed too.
63
+ inline: [
64
+ '@museumwnf/viewer-core',
65
+ '@museumwnf/viewer-layout',
66
+ ...inline,
67
+ ],
68
+ },
69
+ },
70
+ },
71
+ }
72
+ }
@@ -1,53 +1,53 @@
1
- <script setup>
2
- import { computed, ref, watchEffect } from 'vue'
3
- import { useDataPackage } from '../composables/useDataPackage.js'
4
- import { renderBlock } from '../i18n/markdown.js'
5
-
6
- const props = defineProps({
7
- entity: { type: String, required: true },
8
- id: { type: String, required: true },
9
- })
10
-
11
- const { loadEntity } = useDataPackage()
12
-
13
- const record = ref(undefined)
14
- watchEffect(async () => {
15
- // Read both props before the first await so the effect tracks them.
16
- const { entity, id } = props
17
- const all = await loadEntity(entity)
18
- record.value = all.find((r) => String(r.id) === String(id)) ?? null
19
- })
20
-
21
- const fields = computed(() =>
22
- record.value ? Object.entries(record.value).filter(([key]) => key !== 'id') : []
23
- )
24
-
25
- // Through the same pipeline as every text, which escapes raw HTML rather than
26
- // rendering it. A data package holds Markdown: the importer converts the legacy
27
- // HTML on the way in, so HTML reaching a field here is a fault upstream and the
28
- // fix belongs in the importer. Rendering it would hide that, and would make a
29
- // museum record — written by hand, years ago, in another system — the one input
30
- // this application trusts with markup.
31
- function renderValue(value) {
32
- return typeof value === 'string' ? renderBlock(value, { breaks: true }) : JSON.stringify(value)
33
- }
34
- </script>
35
-
36
- <template>
37
- <article class="vc-detail">
38
- <p v-if="record === undefined">{{ $t('core.status.loading') }}</p>
39
- <p v-else-if="record === null">{{ $t('core.detail.notFound') }}</p>
40
- <template v-else>
41
- <h1>{{ record.title ?? record.name ?? record.id }}</h1>
42
- <dl>
43
- <template v-for="[key, value] in fields" :key="key">
44
- <dt>{{ key }}</dt>
45
- <dd v-html="renderValue(value)"></dd>
46
- </template>
47
- </dl>
48
- </template>
49
- <p>
50
- <router-link :to="`/${entity}`">{{ $t('core.nav.backToList') }}</router-link>
51
- </p>
52
- </article>
53
- </template>
1
+ <script setup>
2
+ import { computed, ref, watchEffect } from 'vue'
3
+ import { useDataPackage } from '../composables/useDataPackage.js'
4
+ import { renderBlock } from '../i18n/markdown.js'
5
+
6
+ const props = defineProps({
7
+ entity: { type: String, required: true },
8
+ id: { type: String, required: true },
9
+ })
10
+
11
+ const { loadEntity } = useDataPackage()
12
+
13
+ const record = ref(undefined)
14
+ watchEffect(async () => {
15
+ // Read both props before the first await so the effect tracks them.
16
+ const { entity, id } = props
17
+ const all = await loadEntity(entity)
18
+ record.value = all.find((r) => String(r.id) === String(id)) ?? null
19
+ })
20
+
21
+ const fields = computed(() =>
22
+ record.value ? Object.entries(record.value).filter(([key]) => key !== 'id') : []
23
+ )
24
+
25
+ // Through the same pipeline as every text, which escapes raw HTML rather than
26
+ // rendering it. A data package holds Markdown: the importer converts the legacy
27
+ // HTML on the way in, so HTML reaching a field here is a fault upstream and the
28
+ // fix belongs in the importer. Rendering it would hide that, and would make a
29
+ // museum record — written by hand, years ago, in another system — the one input
30
+ // this application trusts with markup.
31
+ function renderValue(value) {
32
+ return typeof value === 'string' ? renderBlock(value, { breaks: true }) : JSON.stringify(value)
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <article class="vc-detail">
38
+ <p v-if="record === undefined">{{ $t('core.status.loading') }}</p>
39
+ <p v-else-if="record === null">{{ $t('core.detail.notFound') }}</p>
40
+ <template v-else>
41
+ <h1>{{ record.title ?? record.name ?? record.id }}</h1>
42
+ <dl>
43
+ <template v-for="[key, value] in fields" :key="key">
44
+ <dt>{{ key }}</dt>
45
+ <dd v-html="renderValue(value)"></dd>
46
+ </template>
47
+ </dl>
48
+ </template>
49
+ <p>
50
+ <router-link :to="`/${entity}`">{{ $t('core.nav.backToList') }}</router-link>
51
+ </p>
52
+ </article>
53
+ </template>
@@ -1,20 +1,20 @@
1
- <script setup>
2
- import { inject } from 'vue'
3
- import { VIEWER_CONFIG } from '../injectionKeys.js'
4
-
5
- const config = inject(VIEWER_CONFIG, {})
6
- const entities = config.features?.entities ?? []
7
- </script>
8
-
9
- <template>
10
- <section class="vc-home">
11
- <h1>{{ config.siteName }}</h1>
12
- <nav v-if="entities.length">
13
- <ul>
14
- <li v-for="entity in entities" :key="entity">
15
- <router-link :to="`/${entity}`">{{ entity }}</router-link>
16
- </li>
17
- </ul>
18
- </nav>
19
- </section>
20
- </template>
1
+ <script setup>
2
+ import { inject } from 'vue'
3
+ import { VIEWER_CONFIG } from '../injectionKeys.js'
4
+
5
+ const config = inject(VIEWER_CONFIG, {})
6
+ const entities = config.features?.entities ?? []
7
+ </script>
8
+
9
+ <template>
10
+ <section class="vc-home">
11
+ <h1>{{ config.siteName }}</h1>
12
+ <nav v-if="entities.length">
13
+ <ul>
14
+ <li v-for="entity in entities" :key="entity">
15
+ <router-link :to="`/${entity}`">{{ entity }}</router-link>
16
+ </li>
17
+ </ul>
18
+ </nav>
19
+ </section>
20
+ </template>
@@ -1,61 +1,61 @@
1
- <script setup>
2
- import { computed, ref, watchEffect } from 'vue'
3
- import { useRoute } from 'vue-router'
4
- import { useDataPackage } from '../composables/useDataPackage.js'
5
- import { paginate } from '../catalogue/pagination.js'
6
-
7
- const props = defineProps({
8
- entity: { type: String, required: true },
9
- pageSize: { type: Number, default: 20 },
10
- })
11
-
12
- const route = useRoute()
13
- const { loadEntity } = useDataPackage()
14
-
15
- const records = ref(null)
16
- watchEffect(async () => {
17
- records.value = await loadEntity(props.entity)
18
- })
19
-
20
- // The same page arithmetic every website's lists use, so this generic list
21
- // and a site's results page cannot disagree on where page 3 starts.
22
- const paged = computed(() => paginate(records.value ?? [], route.query.page, props.pageSize))
23
- const page = computed(() => paged.value.currentPage)
24
- const totalPages = computed(() => paged.value.lastPage)
25
- const pageRecords = computed(() => paged.value.rows)
26
-
27
- function labelOf(record) {
28
- return record.title ?? record.name ?? record.label ?? record.id
29
- }
30
- </script>
31
-
32
- <template>
33
- <section class="vc-list">
34
- <h1>{{ entity }}</h1>
35
- <p v-if="records === null">{{ $t('core.status.loading') }}</p>
36
- <p v-else-if="records.length === 0">{{ $t('core.list.empty') }}</p>
37
- <template v-else>
38
- <ul>
39
- <li v-for="record in pageRecords" :key="record.id">
40
- <router-link :to="`/${entity}/${record.id}`">{{ labelOf(record) }}</router-link>
41
- </li>
42
- </ul>
43
- <nav v-if="totalPages > 1" class="vc-pagination">
44
- <router-link v-if="page > 1" :to="{ query: { page: page - 1 } }">
45
- {{ $t('core.pagination.previous') }}
46
- </router-link>
47
- <!-- The position is rendered here, next to the texts, rather than
48
- inserted into one: a text that has to carry a number cannot be
49
- translated without carrying the number's place in the sentence
50
- too, and that is the one thing this system does not do. -->
51
- <span class="vc-pagination__position">{{ page }} / {{ totalPages }}</span>
52
- <router-link v-if="page < totalPages" :to="{ query: { page: page + 1 } }">
53
- {{ $t('core.pagination.next') }}
54
- </router-link>
55
- </nav>
56
- </template>
57
- <p>
58
- <router-link to="/">{{ $t('core.nav.home') }}</router-link>
59
- </p>
60
- </section>
61
- </template>
1
+ <script setup>
2
+ import { computed, ref, watchEffect } from 'vue'
3
+ import { useRoute } from 'vue-router'
4
+ import { useDataPackage } from '../composables/useDataPackage.js'
5
+ import { paginate } from '../catalogue/pagination.js'
6
+
7
+ const props = defineProps({
8
+ entity: { type: String, required: true },
9
+ pageSize: { type: Number, default: 20 },
10
+ })
11
+
12
+ const route = useRoute()
13
+ const { loadEntity } = useDataPackage()
14
+
15
+ const records = ref(null)
16
+ watchEffect(async () => {
17
+ records.value = await loadEntity(props.entity)
18
+ })
19
+
20
+ // The same page arithmetic every website's lists use, so this generic list
21
+ // and a site's results page cannot disagree on where page 3 starts.
22
+ const paged = computed(() => paginate(records.value ?? [], route.query.page, props.pageSize))
23
+ const page = computed(() => paged.value.currentPage)
24
+ const totalPages = computed(() => paged.value.lastPage)
25
+ const pageRecords = computed(() => paged.value.rows)
26
+
27
+ function labelOf(record) {
28
+ return record.title ?? record.name ?? record.label ?? record.id
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <section class="vc-list">
34
+ <h1>{{ entity }}</h1>
35
+ <p v-if="records === null">{{ $t('core.status.loading') }}</p>
36
+ <p v-else-if="records.length === 0">{{ $t('core.list.empty') }}</p>
37
+ <template v-else>
38
+ <ul>
39
+ <li v-for="record in pageRecords" :key="record.id">
40
+ <router-link :to="`/${entity}/${record.id}`">{{ labelOf(record) }}</router-link>
41
+ </li>
42
+ </ul>
43
+ <nav v-if="totalPages > 1" class="vc-pagination">
44
+ <router-link v-if="page > 1" :to="{ query: { page: page - 1 } }">
45
+ {{ $t('core.pagination.previous') }}
46
+ </router-link>
47
+ <!-- The position is rendered here, next to the texts, rather than
48
+ inserted into one: a text that has to carry a number cannot be
49
+ translated without carrying the number's place in the sentence
50
+ too, and that is the one thing this system does not do. -->
51
+ <span class="vc-pagination__position">{{ page }} / {{ totalPages }}</span>
52
+ <router-link v-if="page < totalPages" :to="{ query: { page: page + 1 } }">
53
+ {{ $t('core.pagination.next') }}
54
+ </router-link>
55
+ </nav>
56
+ </template>
57
+ <p>
58
+ <router-link to="/">{{ $t('core.nav.home') }}</router-link>
59
+ </p>
60
+ </section>
61
+ </template>