@conduction/nextcloud-vue 2.56.0-beta.7 → 2.56.0-beta.9

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 (87) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +169 -1
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  4. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +88 -4
  5. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  6. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +38 -25
  7. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js +21 -1
  9. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js +4 -1
  11. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +40 -8
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +40 -15
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +2 -2
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +6 -5
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  22. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +3 -1
  23. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  24. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +10 -0
  26. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js.map +1 -0
  27. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +316 -0
  28. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -0
  29. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +105 -0
  30. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -0
  31. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +53 -0
  32. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  33. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +22 -5
  34. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  35. package/dist/esm/components/CnPageRenderer/pageTypes.js +1 -0
  36. package/dist/esm/components/CnPageRenderer/pageTypes.js.map +1 -1
  37. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  38. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnStorePage/CnStorePage.vue2.js.map +1 -1
  40. package/dist/esm/components/CnStorePage/CnStorePage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnWikiPage/CnWikiPage.vue2.js.map +1 -1
  42. package/dist/esm/components/CnWikiPage/CnWikiPage.vue3.js.map +1 -1
  43. package/dist/esm/index.js +6 -0
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/l10n/en.json.js +6 -0
  46. package/dist/esm/l10n/en.json.js.map +1 -1
  47. package/dist/esm/l10n/nl.json.js +6 -0
  48. package/dist/esm/l10n/nl.json.js.map +1 -1
  49. package/dist/esm/nextcloud-vue.css +198 -9
  50. package/dist/esm/utils/headers.js +28 -2
  51. package/dist/esm/utils/headers.js.map +1 -1
  52. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  53. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  54. package/dist/nextcloud-vue.cjs.js +9162 -8283
  55. package/dist/nextcloud-vue.cjs.js.map +2 -2
  56. package/dist/nextcloud-vue.css +198 -9
  57. package/l10n/en.json +6 -0
  58. package/l10n/nl.json +6 -0
  59. package/package.json +3 -2
  60. package/scripts/.jsdoc-baselines.json +1 -0
  61. package/scripts/manifest-schema-hash.js +41 -0
  62. package/scripts/record-manifest-schema-hash.js +52 -0
  63. package/src/components/CnAppNav/CnAppNav.vue +169 -1
  64. package/src/components/CnBannerWidget/CnBannerWidget.vue +122 -4
  65. package/src/components/CnBrandStripe/CnBrandStripe.md +16 -0
  66. package/src/components/CnBrandStripe/CnBrandStripe.vue +58 -5
  67. package/src/components/CnDashboardPage/CnDashboardPage.vue +40 -8
  68. package/src/components/CnDataTable/CnDataTable.md +1 -1
  69. package/src/components/CnDataTable/CnDataTable.vue +42 -17
  70. package/src/components/CnDetailPage/CnDetailPage.vue +4 -1
  71. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +3 -1
  72. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +517 -0
  73. package/src/components/CnLinkCardsPage/index.js +4 -0
  74. package/src/components/CnObjectSidebar/CnFilesTab.vue +65 -0
  75. package/src/components/CnPageRenderer/pageTypes.js +1 -0
  76. package/src/components/CnReportsPage/CnReportsPage.vue +7 -0
  77. package/src/components/CnStorePage/CnStorePage.vue +7 -0
  78. package/src/components/CnWikiPage/CnWikiPage.vue +7 -0
  79. package/src/components/index.js +1 -0
  80. package/src/css/detail-page.css +10 -2
  81. package/src/css/table.css +1 -2
  82. package/src/index.js +7 -0
  83. package/src/public/index.js +5 -0
  84. package/src/schemas/app-manifest-v2.schema.json +45 -3
  85. package/src/types/manifest.d.ts +1 -1
  86. package/src/utils/headers.js +28 -2
  87. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -0,0 +1,316 @@
1
+ import { translate } from '@nextcloud/l10n';
2
+ import { isAppInstalled } from '../../utils/appInstalled.js';
3
+ import { resolveHref, followLinkClick } from '../../utils/linkNavigation.js';
4
+ import { passesContextPredicates } from '../../utils/visibleIfContext.js';
5
+ import __sfc_main from '../CnIcon/CnIcon.vue.js';
6
+
7
+ let linkCardsPageUid = 0;
8
+
9
+ /**
10
+ * CnLinkCardsPage: the `type: "links"` page. Link cards, grouped under captions.
11
+ *
12
+ * One page that opens every page a short menu leaves out. A card has a
13
+ * label, a one-line description, an optional icon and a target. Groups come
14
+ * from `categories`, in the order they are declared; a card names its group
15
+ * with `category`.
16
+ *
17
+ * ```json
18
+ * { "id": "Modules", "route": "/modules", "type": "links", "title": "Modules and more",
19
+ * "config": {
20
+ * "description": "Every page that is not in the daily menu.",
21
+ * "categories": { "sales": "Sales", "marketing": "Marketing" },
22
+ * "cards": [
23
+ * { "id": "Leads", "label": "Leads", "description": "Every deal you are working on.",
24
+ * "icon": "CashMultiple", "category": "sales", "route": "Leads" }
25
+ * ]
26
+ * } }
27
+ * ```
28
+ *
29
+ * Every string goes through the app's translate function. A card is hidden
30
+ * by the same `visibleIf` and `permission` a menu entry takes, and a group
31
+ * with no visible card is not drawn.
32
+ */
33
+ var script = {
34
+ name: 'CnLinkCardsPage',
35
+
36
+ components: { CnIcon: __sfc_main },
37
+
38
+ inject: {
39
+ /** The app's translate function, provided by CnAppRoot. Identity outside one. */
40
+ cnTranslate: { default: () => (key) => key },
41
+ /** The effective manifest, for `visibleIf` predicates against `runtime`. */
42
+ cnManifest: { default: null },
43
+ /** The permissions the user holds. Empty means the app did not say, and allows. */
44
+ cnPermissions: { default: () => [] },
45
+ },
46
+
47
+ props: {
48
+ /** The whole manifest page, for a host that mounts this component itself. CnPageRenderer passes the flattened props below instead. */
49
+ page: {
50
+ type: Object,
51
+ default: () => ({}),
52
+ },
53
+
54
+ /**
55
+ * The cards: `{ id, label, description?, icon?, category?, route?, params?, query?, href?, visibleIf?, permission? }`.
56
+ * `route` is a route name, or a path when it starts with `/`.
57
+ *
58
+ * @type {Array<object>|null}
59
+ */
60
+ cards: {
61
+ type: Array,
62
+ default: null,
63
+ },
64
+
65
+ /**
66
+ * Group key to caption. Sets the order of the groups.
67
+ *
68
+ * @type {Record<string, string>|null}
69
+ */
70
+ categories: {
71
+ type: Object,
72
+ default: null,
73
+ },
74
+
75
+ /** The heading. */
76
+ title: {
77
+ type: String,
78
+ default: null,
79
+ },
80
+
81
+ /** The lead paragraph under the heading. */
82
+ description: {
83
+ type: String,
84
+ default: null,
85
+ },
86
+
87
+ /** Text shown when no card is visible. */
88
+ emptyLabel: {
89
+ type: String,
90
+ default: null,
91
+ },
92
+
93
+ /**
94
+ * Translate function. Falls back to the injected `cnTranslate`.
95
+ *
96
+ * @type {((key: string) => string)|null}
97
+ */
98
+ translate: {
99
+ type: Function,
100
+ default: null,
101
+ },
102
+ },
103
+
104
+ data() {
105
+ linkCardsPageUid += 1;
106
+ return {
107
+ uid: linkCardsPageUid,
108
+ }
109
+ },
110
+
111
+ computed: {
112
+ /**
113
+ * The config, from the flattened props or from `page.config`. Props win.
114
+ *
115
+ * @return {object} The config.
116
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
117
+ */
118
+ config() {
119
+ const fromPage = (this.page && this.page.config) || {};
120
+ const fromProps = {};
121
+ for (const key of ['cards', 'categories', 'description', 'title', 'emptyLabel']) {
122
+ if (this[key] !== null && this[key] !== undefined) {
123
+ fromProps[key] = this[key];
124
+ }
125
+ }
126
+ return { ...fromPage, ...fromProps }
127
+ },
128
+
129
+ /**
130
+ * The heading, translated.
131
+ *
132
+ * @return {string} The title, or '' when the page declares none.
133
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
134
+ */
135
+ resolvedTitle() {
136
+ return this.tr(this.config.title || (this.page && this.page.title) || '')
137
+ },
138
+
139
+ /**
140
+ * The lead paragraph, translated.
141
+ *
142
+ * @return {string} The description.
143
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
144
+ */
145
+ resolvedDescription() {
146
+ return this.tr(this.config.description || '')
147
+ },
148
+
149
+ /**
150
+ * The empty-state text.
151
+ *
152
+ * @return {string} The text.
153
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
154
+ */
155
+ resolvedEmptyLabel() {
156
+ return this.config.emptyLabel ? this.tr(this.config.emptyLabel) : translate('nextcloud-vue', 'Nothing to open here.')
157
+ },
158
+
159
+ /**
160
+ * The cards a reader may see and can open, with text translated and
161
+ * the link resolved. A card that is gated away, or has no target that
162
+ * resolves, is left out: a card that does nothing is worse than none.
163
+ *
164
+ * @return {Array<object>} The cards.
165
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link
166
+ */
167
+ visibleCards() {
168
+ const declared = Array.isArray(this.config.cards) ? this.config.cards : [];
169
+ const runtime = this.cnManifest?.runtime ?? null;
170
+ return declared
171
+ .filter((card) => card && card.id && card.label)
172
+ .filter((card) => this.passesPermission(card) && this.passesVisibleIf(card, runtime))
173
+ .map((card) => {
174
+ const target = this.targetOf(card);
175
+ return {
176
+ id: String(card.id),
177
+ label: this.tr(card.label),
178
+ description: this.tr(card.description || ''),
179
+ descriptionId: `cn-link-card-desc-${this.uid}-${String(card.id).replace(/[^A-Za-z0-9_-]/g, '-')}`,
180
+ icon: card.icon || '',
181
+ category: card.category || '',
182
+ external: target.external,
183
+ location: target.location,
184
+ url: target.url,
185
+ }
186
+ })
187
+ .filter((card) => card.url !== '')
188
+ },
189
+
190
+ /**
191
+ * The groups to draw: ungrouped cards first under no caption, then
192
+ * one group per category in declaration order. A group without a
193
+ * visible card is left out.
194
+ *
195
+ * @return {Array<{key: string, label: string, headingId: string, cards: Array<object>}>} The groups.
196
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
197
+ */
198
+ groups() {
199
+ const declared = (this.config.categories && typeof this.config.categories === 'object') ? this.config.categories : {};
200
+ const keys = Object.keys(declared);
201
+ const loose = this.visibleCards.filter((card) => !keys.includes(card.category));
202
+ const groups = loose.length > 0 ? [{ key: '', label: '', headingId: '', cards: loose }] : [];
203
+ keys.forEach((key, index) => {
204
+ const cards = this.visibleCards.filter((card) => card.category === key);
205
+ if (cards.length > 0) {
206
+ groups.push({
207
+ key,
208
+ label: this.tr(declared[key]),
209
+ headingId: `cn-link-cards-group-${this.uid}-${index}`,
210
+ cards,
211
+ });
212
+ }
213
+ });
214
+ return groups
215
+ },
216
+ },
217
+
218
+ methods: {
219
+ /**
220
+ * Translate a manifest string through the app's translate function.
221
+ *
222
+ * @param {string} value The declared string.
223
+ * @return {string} The translation, or the input unchanged.
224
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
225
+ */
226
+ tr(value) {
227
+ if (typeof value !== 'string' || value === '') {
228
+ return value
229
+ }
230
+ return (this.translate ?? this.cnTranslate)(value)
231
+ },
232
+
233
+ /**
234
+ * Whether the user holds the card's `permission`. Same rule as the
235
+ * menu: no permission declared, or no permissions known, allows.
236
+ *
237
+ * @param {object} card The card.
238
+ * @return {boolean} True when the card may render.
239
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions
240
+ */
241
+ passesPermission(card) {
242
+ const held = Array.isArray(this.cnPermissions) ? this.cnPermissions : [];
243
+ if (!card.permission || held.length === 0) {
244
+ return true
245
+ }
246
+ return held.includes(card.permission)
247
+ },
248
+
249
+ /**
250
+ * Evaluate a card's `visibleIf` the way CnAppNav evaluates a menu
251
+ * entry's: `appInstalled` first, then dot-path predicates against
252
+ * the manifest's `runtime`.
253
+ *
254
+ * @param {object} card The card.
255
+ * @param {object|null} runtime `manifest.runtime`, or null.
256
+ * @return {boolean} True when the card may render.
257
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions
258
+ */
259
+ passesVisibleIf(card, runtime) {
260
+ const condition = card.visibleIf;
261
+ if (!condition || typeof condition !== 'object') {
262
+ return true
263
+ }
264
+ if (condition.appInstalled && !isAppInstalled(condition.appInstalled)) {
265
+ return false
266
+ }
267
+ return passesContextPredicates(condition, runtime)
268
+ },
269
+
270
+ /**
271
+ * Where a card goes. An `href` is an external URL. A `route` is a
272
+ * route name, or a path when it starts with `/`, resolved by the
273
+ * router so the link carries the app's base with or without
274
+ * `/index.php`.
275
+ *
276
+ * @param {object} card The card.
277
+ * @return {{external: boolean, location: object|null, url: string}} The target; `url` is '' when it cannot be resolved.
278
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link
279
+ */
280
+ targetOf(card) {
281
+ if (typeof card.route === 'string' && card.route !== '') {
282
+ const location = card.route.startsWith('/') ? { path: card.route } : { name: card.route };
283
+ if (card.params && typeof card.params === 'object') {
284
+ location.params = card.params;
285
+ }
286
+ if (card.query && typeof card.query === 'object') {
287
+ location.query = card.query;
288
+ }
289
+ return { external: false, location, url: resolveHref(location, this.$router) }
290
+ }
291
+ if (typeof card.href === 'string' && card.href !== '') {
292
+ return { external: true, location: null, url: card.href }
293
+ }
294
+ return { external: false, location: null, url: '' }
295
+ },
296
+
297
+ /**
298
+ * Route a plain click on an in-app card. A modified or middle click,
299
+ * and every click on an external card, is left to the browser.
300
+ *
301
+ * @param {object} card The resolved card.
302
+ * @param {MouseEvent} event The click.
303
+ * @return {void}
304
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link
305
+ */
306
+ open(card, event) {
307
+ if (card.external || !card.location) {
308
+ return
309
+ }
310
+ followLinkClick(event, card.location, this.$router);
311
+ },
312
+ },
313
+ };
314
+
315
+ export { script as default };
316
+ //# sourceMappingURL=CnLinkCardsPage.vue2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CnLinkCardsPage.vue2.js","sources":["../../../../src/components/CnLinkCardsPage/CnLinkCardsPage.vue"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-link-cards-page\" data-testid=\"cn-link-cards-page\">\n\t\t<div class=\"cn-link-cards-page__header\">\n\t\t\t<h2 v-if=\"resolvedTitle\" class=\"cn-link-cards-page__title\">\n\t\t\t\t{{ resolvedTitle }}\n\t\t\t</h2>\n\t\t\t<p v-if=\"resolvedDescription\" class=\"cn-link-cards-page__description\">\n\t\t\t\t{{ resolvedDescription }}\n\t\t\t</p>\n\t\t</div>\n\n\t\t<p\n\t\t\tv-if=\"groups.length === 0\"\n\t\t\tclass=\"cn-link-cards-page__empty\"\n\t\t\tdata-testid=\"cn-link-cards-empty\">\n\t\t\t{{ resolvedEmptyLabel }}\n\t\t</p>\n\n\t\t<section\n\t\t\tv-for=\"group in groups\"\n\t\t\t:key=\"group.key\"\n\t\t\tclass=\"cn-link-cards-page__group\"\n\t\t\t:aria-labelledby=\"group.label ? group.headingId : null\"\n\t\t\t:aria-label=\"group.label ? null : resolvedTitle || null\"\n\t\t\tdata-testid=\"cn-link-cards-group\"\n\t\t\t:data-group=\"group.key\">\n\t\t\t<h3\n\t\t\t\tv-if=\"group.label\"\n\t\t\t\t:id=\"group.headingId\"\n\t\t\t\tclass=\"cn-link-cards-page__caption\">\n\t\t\t\t{{ group.label }}\n\t\t\t</h3>\n\t\t\t<ul class=\"cn-link-cards-page__grid\">\n\t\t\t\t<li v-for=\"card in group.cards\" :key=\"card.id\" class=\"cn-link-cards-page__cell\">\n\t\t\t\t\t<!-- A real <a href>, so the card can be middle-clicked, copied\n\t\t\t\t\t and announced as a link. A plain click on an in-app card\n\t\t\t\t\t is routed, which keeps the page from reloading. -->\n\t\t\t\t\t<a\n\t\t\t\t\t\tclass=\"cn-link-cards-page__card\"\n\t\t\t\t\t\tdata-testid=\"cn-link-card\"\n\t\t\t\t\t\t:data-card-id=\"card.id\"\n\t\t\t\t\t\t:href=\"card.url\"\n\t\t\t\t\t\t:target=\"card.external ? '_blank' : null\"\n\t\t\t\t\t\t:rel=\"card.external ? 'noopener noreferrer' : null\"\n\t\t\t\t\t\t:aria-describedby=\"card.description ? card.descriptionId : null\"\n\t\t\t\t\t\t@click=\"open(card, $event)\">\n\t\t\t\t\t\t<CnIcon\n\t\t\t\t\t\t\tv-if=\"card.icon\"\n\t\t\t\t\t\t\t:name=\"card.icon\"\n\t\t\t\t\t\t\t:size=\"24\"\n\t\t\t\t\t\t\tclass=\"cn-link-cards-page__icon\"\n\t\t\t\t\t\t\taria-hidden=\"true\" />\n\t\t\t\t\t\t<span class=\"cn-link-cards-page__body\">\n\t\t\t\t\t\t\t<span class=\"cn-link-cards-page__label\">{{ card.label }}</span>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tv-if=\"card.description\"\n\t\t\t\t\t\t\t\t:id=\"card.descriptionId\"\n\t\t\t\t\t\t\t\tclass=\"cn-link-cards-page__card-description\">\n\t\t\t\t\t\t\t\t{{ card.description }}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</a>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t</section>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { isAppInstalled } from '../../utils/appInstalled.js'\nimport { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'\nimport { passesContextPredicates } from '../../utils/visibleIfContext.js'\nimport { CnIcon } from '../CnIcon/index.js'\n\nlet linkCardsPageUid = 0\n\n/**\n * CnLinkCardsPage: the `type: \"links\"` page. Link cards, grouped under captions.\n *\n * One page that opens every page a short menu leaves out. A card has a\n * label, a one-line description, an optional icon and a target. Groups come\n * from `categories`, in the order they are declared; a card names its group\n * with `category`.\n *\n * ```json\n * { \"id\": \"Modules\", \"route\": \"/modules\", \"type\": \"links\", \"title\": \"Modules and more\",\n * \"config\": {\n * \"description\": \"Every page that is not in the daily menu.\",\n * \"categories\": { \"sales\": \"Sales\", \"marketing\": \"Marketing\" },\n * \"cards\": [\n * { \"id\": \"Leads\", \"label\": \"Leads\", \"description\": \"Every deal you are working on.\",\n * \"icon\": \"CashMultiple\", \"category\": \"sales\", \"route\": \"Leads\" }\n * ]\n * } }\n * ```\n *\n * Every string goes through the app's translate function. A card is hidden\n * by the same `visibleIf` and `permission` a menu entry takes, and a group\n * with no visible card is not drawn.\n */\nexport default {\n\tname: 'CnLinkCardsPage',\n\n\tcomponents: { CnIcon },\n\n\tinject: {\n\t\t/** The app's translate function, provided by CnAppRoot. Identity outside one. */\n\t\tcnTranslate: { default: () => (key) => key },\n\t\t/** The effective manifest, for `visibleIf` predicates against `runtime`. */\n\t\tcnManifest: { default: null },\n\t\t/** The permissions the user holds. Empty means the app did not say, and allows. */\n\t\tcnPermissions: { default: () => [] },\n\t},\n\n\tprops: {\n\t\t/** The whole manifest page, for a host that mounts this component itself. CnPageRenderer passes the flattened props below instead. */\n\t\tpage: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The cards: `{ id, label, description?, icon?, category?, route?, params?, query?, href?, visibleIf?, permission? }`.\n\t\t * `route` is a route name, or a path when it starts with `/`.\n\t\t *\n\t\t * @type {Array<object>|null}\n\t\t */\n\t\tcards: {\n\t\t\ttype: Array,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Group key to caption. Sets the order of the groups.\n\t\t *\n\t\t * @type {Record<string, string>|null}\n\t\t */\n\t\tcategories: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The heading. */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The lead paragraph under the heading. */\n\t\tdescription: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Text shown when no card is visible. */\n\t\temptyLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Translate function. Falls back to the injected `cnTranslate`.\n\t\t *\n\t\t * @type {((key: string) => string)|null}\n\t\t */\n\t\ttranslate: {\n\t\t\ttype: Function,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\tdata() {\n\t\tlinkCardsPageUid += 1\n\t\treturn {\n\t\t\tuid: linkCardsPageUid,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The config, from the flattened props or from `page.config`. Props win.\n\t\t *\n\t\t * @return {object} The config.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tconfig() {\n\t\t\tconst fromPage = (this.page && this.page.config) || {}\n\t\t\tconst fromProps = {}\n\t\t\tfor (const key of ['cards', 'categories', 'description', 'title', 'emptyLabel']) {\n\t\t\t\tif (this[key] !== null && this[key] !== undefined) {\n\t\t\t\t\tfromProps[key] = this[key]\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn { ...fromPage, ...fromProps }\n\t\t},\n\n\t\t/**\n\t\t * The heading, translated.\n\t\t *\n\t\t * @return {string} The title, or '' when the page declares none.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tresolvedTitle() {\n\t\t\treturn this.tr(this.config.title || (this.page && this.page.title) || '')\n\t\t},\n\n\t\t/**\n\t\t * The lead paragraph, translated.\n\t\t *\n\t\t * @return {string} The description.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tresolvedDescription() {\n\t\t\treturn this.tr(this.config.description || '')\n\t\t},\n\n\t\t/**\n\t\t * The empty-state text.\n\t\t *\n\t\t * @return {string} The text.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tresolvedEmptyLabel() {\n\t\t\treturn this.config.emptyLabel ? this.tr(this.config.emptyLabel) : t('nextcloud-vue', 'Nothing to open here.')\n\t\t},\n\n\t\t/**\n\t\t * The cards a reader may see and can open, with text translated and\n\t\t * the link resolved. A card that is gated away, or has no target that\n\t\t * resolves, is left out: a card that does nothing is worse than none.\n\t\t *\n\t\t * @return {Array<object>} The cards.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link\n\t\t */\n\t\tvisibleCards() {\n\t\t\tconst declared = Array.isArray(this.config.cards) ? this.config.cards : []\n\t\t\tconst runtime = this.cnManifest?.runtime ?? null\n\t\t\treturn declared\n\t\t\t\t.filter((card) => card && card.id && card.label)\n\t\t\t\t.filter((card) => this.passesPermission(card) && this.passesVisibleIf(card, runtime))\n\t\t\t\t.map((card) => {\n\t\t\t\t\tconst target = this.targetOf(card)\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: String(card.id),\n\t\t\t\t\t\tlabel: this.tr(card.label),\n\t\t\t\t\t\tdescription: this.tr(card.description || ''),\n\t\t\t\t\t\tdescriptionId: `cn-link-card-desc-${this.uid}-${String(card.id).replace(/[^A-Za-z0-9_-]/g, '-')}`,\n\t\t\t\t\t\ticon: card.icon || '',\n\t\t\t\t\t\tcategory: card.category || '',\n\t\t\t\t\t\texternal: target.external,\n\t\t\t\t\t\tlocation: target.location,\n\t\t\t\t\t\turl: target.url,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((card) => card.url !== '')\n\t\t},\n\n\t\t/**\n\t\t * The groups to draw: ungrouped cards first under no caption, then\n\t\t * one group per category in declaration order. A group without a\n\t\t * visible card is left out.\n\t\t *\n\t\t * @return {Array<{key: string, label: string, headingId: string, cards: Array<object>}>} The groups.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tgroups() {\n\t\t\tconst declared = (this.config.categories && typeof this.config.categories === 'object') ? this.config.categories : {}\n\t\t\tconst keys = Object.keys(declared)\n\t\t\tconst loose = this.visibleCards.filter((card) => !keys.includes(card.category))\n\t\t\tconst groups = loose.length > 0 ? [{ key: '', label: '', headingId: '', cards: loose }] : []\n\t\t\tkeys.forEach((key, index) => {\n\t\t\t\tconst cards = this.visibleCards.filter((card) => card.category === key)\n\t\t\t\tif (cards.length > 0) {\n\t\t\t\t\tgroups.push({\n\t\t\t\t\t\tkey,\n\t\t\t\t\t\tlabel: this.tr(declared[key]),\n\t\t\t\t\t\theadingId: `cn-link-cards-group-${this.uid}-${index}`,\n\t\t\t\t\t\tcards,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t})\n\t\t\treturn groups\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Translate a manifest string through the app's translate function.\n\t\t *\n\t\t * @param {string} value The declared string.\n\t\t * @return {string} The translation, or the input unchanged.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\ttr(value) {\n\t\t\tif (typeof value !== 'string' || value === '') {\n\t\t\t\treturn value\n\t\t\t}\n\t\t\treturn (this.translate ?? this.cnTranslate)(value)\n\t\t},\n\n\t\t/**\n\t\t * Whether the user holds the card's `permission`. Same rule as the\n\t\t * menu: no permission declared, or no permissions known, allows.\n\t\t *\n\t\t * @param {object} card The card.\n\t\t * @return {boolean} True when the card may render.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions\n\t\t */\n\t\tpassesPermission(card) {\n\t\t\tconst held = Array.isArray(this.cnPermissions) ? this.cnPermissions : []\n\t\t\tif (!card.permission || held.length === 0) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn held.includes(card.permission)\n\t\t},\n\n\t\t/**\n\t\t * Evaluate a card's `visibleIf` the way CnAppNav evaluates a menu\n\t\t * entry's: `appInstalled` first, then dot-path predicates against\n\t\t * the manifest's `runtime`.\n\t\t *\n\t\t * @param {object} card The card.\n\t\t * @param {object|null} runtime `manifest.runtime`, or null.\n\t\t * @return {boolean} True when the card may render.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions\n\t\t */\n\t\tpassesVisibleIf(card, runtime) {\n\t\t\tconst condition = card.visibleIf\n\t\t\tif (!condition || typeof condition !== 'object') {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif (condition.appInstalled && !isAppInstalled(condition.appInstalled)) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn passesContextPredicates(condition, runtime)\n\t\t},\n\n\t\t/**\n\t\t * Where a card goes. An `href` is an external URL. A `route` is a\n\t\t * route name, or a path when it starts with `/`, resolved by the\n\t\t * router so the link carries the app's base with or without\n\t\t * `/index.php`.\n\t\t *\n\t\t * @param {object} card The card.\n\t\t * @return {{external: boolean, location: object|null, url: string}} The target; `url` is '' when it cannot be resolved.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link\n\t\t */\n\t\ttargetOf(card) {\n\t\t\tif (typeof card.route === 'string' && card.route !== '') {\n\t\t\t\tconst location = card.route.startsWith('/') ? { path: card.route } : { name: card.route }\n\t\t\t\tif (card.params && typeof card.params === 'object') {\n\t\t\t\t\tlocation.params = card.params\n\t\t\t\t}\n\t\t\t\tif (card.query && typeof card.query === 'object') {\n\t\t\t\t\tlocation.query = card.query\n\t\t\t\t}\n\t\t\t\treturn { external: false, location, url: resolveHref(location, this.$router) }\n\t\t\t}\n\t\t\tif (typeof card.href === 'string' && card.href !== '') {\n\t\t\t\treturn { external: true, location: null, url: card.href }\n\t\t\t}\n\t\t\treturn { external: false, location: null, url: '' }\n\t\t},\n\n\t\t/**\n\t\t * Route a plain click on an in-app card. A modified or middle click,\n\t\t * and every click on an external card, is left to the browser.\n\t\t *\n\t\t * @param {object} card The resolved card.\n\t\t * @param {MouseEvent} event The click.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link\n\t\t */\n\t\topen(card, event) {\n\t\t\tif (card.external || !card.location) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfollowLinkClick(event, card.location, this.$router)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-link-cards-page {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: calc(6 * var(--default-grid-baseline));\n\tpadding: calc(5 * var(--default-grid-baseline));\n}\n\n/* Clear the Nextcloud navigation toggle button (44px wide, absolutely\n positioned at the left edge of .app-content) plus 12px breathing room, as\n CnPageHeader does. Only the header shifts; the cards keep the full width. */\n.cn-link-cards-page__header {\n\tpadding-inline-start: 56px;\n}\n\n.cn-link-cards-page__title {\n\tmargin: 0 0 var(--default-grid-baseline);\n}\n\n.cn-link-cards-page__description,\n.cn-link-cards-page__empty {\n\tcolor: var(--color-text-maxcontrast);\n\tmargin: 0;\n\tmax-width: 70ch;\n}\n\n.cn-link-cards-page__group {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: calc(3 * var(--default-grid-baseline));\n}\n\n.cn-link-cards-page__caption {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.85em;\n\tfont-weight: 600;\n\tletter-spacing: 0.06em;\n\tmargin: 0;\n\ttext-transform: uppercase;\n}\n\n.cn-link-cards-page__grid {\n\tdisplay: grid;\n\tgap: calc(3 * var(--default-grid-baseline));\n\tgrid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n\tlist-style: none;\n\tmargin: 0;\n\tpadding: 0;\n}\n\n.cn-link-cards-page__cell {\n\tdisplay: flex;\n}\n\n.cn-link-cards-page__card {\n\talign-items: flex-start;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border-dark);\n\tborder-radius: var(--border-radius-large);\n\tcolor: var(--color-main-text);\n\tdisplay: flex;\n\tflex: 1;\n\tgap: calc(3 * var(--default-grid-baseline));\n\tmin-width: 0;\n\tpadding: calc(4 * var(--default-grid-baseline));\n\ttransition: background-color var(--animation-quick, 100ms) ease, border-color var(--animation-quick, 100ms) ease, box-shadow var(--animation-quick, 100ms) ease;\n}\n\n/* A card is a link, and it must not look like a line of prose. A theme may\n underline every link with `!important` (thematiq: `a { text-decoration:\n underline !important }`, specificity 0,0,1). Among `!important` rules the\n higher specificity wins, so this class rule has to carry it too; without it\n the label and the description both rendered underlined on a live Nextcloud. */\n.cn-link-cards-page__card,\n.cn-link-cards-page__card:hover,\n.cn-link-cards-page__card:focus,\n.cn-link-cards-page__card:active {\n\ttext-decoration: none !important;\n}\n\n/* Hover moves the whole card: border, background and a soft lift. */\n.cn-link-cards-page__card:hover {\n\tbackground: var(--color-background-hover);\n\tborder-color: var(--color-primary-element);\n\tbox-shadow: 0 2px 6px var(--color-box-shadow);\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t.cn-link-cards-page__card {\n\t\ttransition: none;\n\t}\n}\n\n.cn-link-cards-page__card:focus-visible {\n\tborder-color: var(--color-primary-element);\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n}\n\n.cn-link-cards-page__icon {\n\tcolor: var(--color-primary-element);\n\tflex: none;\n}\n\n.cn-link-cards-page__body {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: var(--default-grid-baseline);\n\tmin-width: 0;\n}\n\n/* The label is the card's title. Its colour is set here, on the span, because\n a theme may force its link colour on the `a` itself; an inherited link\n colour made the title read as a hyperlink. */\n.cn-link-cards-page__label {\n\tcolor: var(--color-main-text);\n\tfont-size: 1.05em;\n\tfont-weight: 700;\n\tline-height: 1.3;\n\toverflow-wrap: anywhere;\n}\n\n.cn-link-cards-page__card-description {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.9em;\n\tline-height: 1.4;\n}\n</style>\n"],"names":["CnIcon","t"],"mappings":"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"}
@@ -0,0 +1,105 @@
1
+ import { resolveComponent, openBlock, createElementBlock, createElementVNode, toDisplayString, createCommentVNode, Fragment, renderList, createBlock } from 'vue';
2
+
3
+ const _hoisted_1 = {
4
+ class: "cn-link-cards-page",
5
+ "data-testid": "cn-link-cards-page"
6
+ };
7
+ const _hoisted_2 = { class: "cn-link-cards-page__header" };
8
+ const _hoisted_3 = {
9
+ key: 0,
10
+ class: "cn-link-cards-page__title"
11
+ };
12
+ const _hoisted_4 = {
13
+ key: 1,
14
+ class: "cn-link-cards-page__description"
15
+ };
16
+ const _hoisted_5 = {
17
+ key: 0,
18
+ class: "cn-link-cards-page__empty",
19
+ "data-testid": "cn-link-cards-empty"
20
+ };
21
+ const _hoisted_6 = ["aria-labelledby", "aria-label", "data-group"];
22
+ const _hoisted_7 = ["id"];
23
+ const _hoisted_8 = { class: "cn-link-cards-page__grid" };
24
+ const _hoisted_9 = ["data-card-id", "href", "target", "rel", "aria-describedby", "onClick"];
25
+ const _hoisted_10 = { class: "cn-link-cards-page__body" };
26
+ const _hoisted_11 = { class: "cn-link-cards-page__label" };
27
+ const _hoisted_12 = ["id"];
28
+
29
+ function render(_ctx, _cache, $props, $setup, $data, $options) {
30
+ const _component_CnIcon = resolveComponent("CnIcon");
31
+
32
+ return (openBlock(), createElementBlock("div", _hoisted_1, [
33
+ createElementVNode("div", _hoisted_2, [
34
+ ($options.resolvedTitle)
35
+ ? (openBlock(), createElementBlock("h2", _hoisted_3, toDisplayString($options.resolvedTitle), 1 /* TEXT */))
36
+ : createCommentVNode("v-if", true),
37
+ ($options.resolvedDescription)
38
+ ? (openBlock(), createElementBlock("p", _hoisted_4, toDisplayString($options.resolvedDescription), 1 /* TEXT */))
39
+ : createCommentVNode("v-if", true)
40
+ ]),
41
+ ($options.groups.length === 0)
42
+ ? (openBlock(), createElementBlock("p", _hoisted_5, toDisplayString($options.resolvedEmptyLabel), 1 /* TEXT */))
43
+ : createCommentVNode("v-if", true),
44
+ (openBlock(true), createElementBlock(Fragment, null, renderList($options.groups, (group) => {
45
+ return (openBlock(), createElementBlock("section", {
46
+ key: group.key,
47
+ class: "cn-link-cards-page__group",
48
+ "aria-labelledby": group.label ? group.headingId : null,
49
+ "aria-label": group.label ? null : $options.resolvedTitle || null,
50
+ "data-testid": "cn-link-cards-group",
51
+ "data-group": group.key
52
+ }, [
53
+ (group.label)
54
+ ? (openBlock(), createElementBlock("h3", {
55
+ key: 0,
56
+ id: group.headingId,
57
+ class: "cn-link-cards-page__caption"
58
+ }, toDisplayString(group.label), 9 /* TEXT, PROPS */, _hoisted_7))
59
+ : createCommentVNode("v-if", true),
60
+ createElementVNode("ul", _hoisted_8, [
61
+ (openBlock(true), createElementBlock(Fragment, null, renderList(group.cards, (card) => {
62
+ return (openBlock(), createElementBlock("li", {
63
+ key: card.id,
64
+ class: "cn-link-cards-page__cell"
65
+ }, [
66
+ createElementVNode("a", {
67
+ class: "cn-link-cards-page__card",
68
+ "data-testid": "cn-link-card",
69
+ "data-card-id": card.id,
70
+ href: card.url,
71
+ target: card.external ? '_blank' : null,
72
+ rel: card.external ? 'noopener noreferrer' : null,
73
+ "aria-describedby": card.description ? card.descriptionId : null,
74
+ onClick: $event => ($options.open(card, $event))
75
+ }, [
76
+ (card.icon)
77
+ ? (openBlock(), createBlock(_component_CnIcon, {
78
+ key: 0,
79
+ name: card.icon,
80
+ size: 24,
81
+ class: "cn-link-cards-page__icon",
82
+ "aria-hidden": "true"
83
+ }, null, 8 /* PROPS */, ["name"]))
84
+ : createCommentVNode("v-if", true),
85
+ createElementVNode("span", _hoisted_10, [
86
+ createElementVNode("span", _hoisted_11, toDisplayString(card.label), 1 /* TEXT */),
87
+ (card.description)
88
+ ? (openBlock(), createElementBlock("span", {
89
+ key: 0,
90
+ id: card.descriptionId,
91
+ class: "cn-link-cards-page__card-description"
92
+ }, toDisplayString(card.description), 9 /* TEXT, PROPS */, _hoisted_12))
93
+ : createCommentVNode("v-if", true)
94
+ ])
95
+ ], 8 /* PROPS */, _hoisted_9)
96
+ ]))
97
+ }), 128 /* KEYED_FRAGMENT */))
98
+ ])
99
+ ], 8 /* PROPS */, _hoisted_6))
100
+ }), 128 /* KEYED_FRAGMENT */))
101
+ ]))
102
+ }
103
+
104
+ export { render };
105
+ //# sourceMappingURL=CnLinkCardsPage.vue3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CnLinkCardsPage.vue3.js","sources":["../../../../src/components/CnLinkCardsPage/CnLinkCardsPage.vue?vue&type=template&id=2f9a546b&lang.js"],"sourcesContent":["<!--\n - SPDX-FileCopyrightText: 2026 Conduction B.V.\n - SPDX-License-Identifier: EUPL-1.2\n-->\n\n<template>\n\t<div class=\"cn-link-cards-page\" data-testid=\"cn-link-cards-page\">\n\t\t<div class=\"cn-link-cards-page__header\">\n\t\t\t<h2 v-if=\"resolvedTitle\" class=\"cn-link-cards-page__title\">\n\t\t\t\t{{ resolvedTitle }}\n\t\t\t</h2>\n\t\t\t<p v-if=\"resolvedDescription\" class=\"cn-link-cards-page__description\">\n\t\t\t\t{{ resolvedDescription }}\n\t\t\t</p>\n\t\t</div>\n\n\t\t<p\n\t\t\tv-if=\"groups.length === 0\"\n\t\t\tclass=\"cn-link-cards-page__empty\"\n\t\t\tdata-testid=\"cn-link-cards-empty\">\n\t\t\t{{ resolvedEmptyLabel }}\n\t\t</p>\n\n\t\t<section\n\t\t\tv-for=\"group in groups\"\n\t\t\t:key=\"group.key\"\n\t\t\tclass=\"cn-link-cards-page__group\"\n\t\t\t:aria-labelledby=\"group.label ? group.headingId : null\"\n\t\t\t:aria-label=\"group.label ? null : resolvedTitle || null\"\n\t\t\tdata-testid=\"cn-link-cards-group\"\n\t\t\t:data-group=\"group.key\">\n\t\t\t<h3\n\t\t\t\tv-if=\"group.label\"\n\t\t\t\t:id=\"group.headingId\"\n\t\t\t\tclass=\"cn-link-cards-page__caption\">\n\t\t\t\t{{ group.label }}\n\t\t\t</h3>\n\t\t\t<ul class=\"cn-link-cards-page__grid\">\n\t\t\t\t<li v-for=\"card in group.cards\" :key=\"card.id\" class=\"cn-link-cards-page__cell\">\n\t\t\t\t\t<!-- A real <a href>, so the card can be middle-clicked, copied\n\t\t\t\t\t and announced as a link. A plain click on an in-app card\n\t\t\t\t\t is routed, which keeps the page from reloading. -->\n\t\t\t\t\t<a\n\t\t\t\t\t\tclass=\"cn-link-cards-page__card\"\n\t\t\t\t\t\tdata-testid=\"cn-link-card\"\n\t\t\t\t\t\t:data-card-id=\"card.id\"\n\t\t\t\t\t\t:href=\"card.url\"\n\t\t\t\t\t\t:target=\"card.external ? '_blank' : null\"\n\t\t\t\t\t\t:rel=\"card.external ? 'noopener noreferrer' : null\"\n\t\t\t\t\t\t:aria-describedby=\"card.description ? card.descriptionId : null\"\n\t\t\t\t\t\t@click=\"open(card, $event)\">\n\t\t\t\t\t\t<CnIcon\n\t\t\t\t\t\t\tv-if=\"card.icon\"\n\t\t\t\t\t\t\t:name=\"card.icon\"\n\t\t\t\t\t\t\t:size=\"24\"\n\t\t\t\t\t\t\tclass=\"cn-link-cards-page__icon\"\n\t\t\t\t\t\t\taria-hidden=\"true\" />\n\t\t\t\t\t\t<span class=\"cn-link-cards-page__body\">\n\t\t\t\t\t\t\t<span class=\"cn-link-cards-page__label\">{{ card.label }}</span>\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tv-if=\"card.description\"\n\t\t\t\t\t\t\t\t:id=\"card.descriptionId\"\n\t\t\t\t\t\t\t\tclass=\"cn-link-cards-page__card-description\">\n\t\t\t\t\t\t\t\t{{ card.description }}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</a>\n\t\t\t\t</li>\n\t\t\t</ul>\n\t\t</section>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { isAppInstalled } from '../../utils/appInstalled.js'\nimport { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'\nimport { passesContextPredicates } from '../../utils/visibleIfContext.js'\nimport { CnIcon } from '../CnIcon/index.js'\n\nlet linkCardsPageUid = 0\n\n/**\n * CnLinkCardsPage: the `type: \"links\"` page. Link cards, grouped under captions.\n *\n * One page that opens every page a short menu leaves out. A card has a\n * label, a one-line description, an optional icon and a target. Groups come\n * from `categories`, in the order they are declared; a card names its group\n * with `category`.\n *\n * ```json\n * { \"id\": \"Modules\", \"route\": \"/modules\", \"type\": \"links\", \"title\": \"Modules and more\",\n * \"config\": {\n * \"description\": \"Every page that is not in the daily menu.\",\n * \"categories\": { \"sales\": \"Sales\", \"marketing\": \"Marketing\" },\n * \"cards\": [\n * { \"id\": \"Leads\", \"label\": \"Leads\", \"description\": \"Every deal you are working on.\",\n * \"icon\": \"CashMultiple\", \"category\": \"sales\", \"route\": \"Leads\" }\n * ]\n * } }\n * ```\n *\n * Every string goes through the app's translate function. A card is hidden\n * by the same `visibleIf` and `permission` a menu entry takes, and a group\n * with no visible card is not drawn.\n */\nexport default {\n\tname: 'CnLinkCardsPage',\n\n\tcomponents: { CnIcon },\n\n\tinject: {\n\t\t/** The app's translate function, provided by CnAppRoot. Identity outside one. */\n\t\tcnTranslate: { default: () => (key) => key },\n\t\t/** The effective manifest, for `visibleIf` predicates against `runtime`. */\n\t\tcnManifest: { default: null },\n\t\t/** The permissions the user holds. Empty means the app did not say, and allows. */\n\t\tcnPermissions: { default: () => [] },\n\t},\n\n\tprops: {\n\t\t/** The whole manifest page, for a host that mounts this component itself. CnPageRenderer passes the flattened props below instead. */\n\t\tpage: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The cards: `{ id, label, description?, icon?, category?, route?, params?, query?, href?, visibleIf?, permission? }`.\n\t\t * `route` is a route name, or a path when it starts with `/`.\n\t\t *\n\t\t * @type {Array<object>|null}\n\t\t */\n\t\tcards: {\n\t\t\ttype: Array,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Group key to caption. Sets the order of the groups.\n\t\t *\n\t\t * @type {Record<string, string>|null}\n\t\t */\n\t\tcategories: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The heading. */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** The lead paragraph under the heading. */\n\t\tdescription: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Text shown when no card is visible. */\n\t\temptyLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Translate function. Falls back to the injected `cnTranslate`.\n\t\t *\n\t\t * @type {((key: string) => string)|null}\n\t\t */\n\t\ttranslate: {\n\t\t\ttype: Function,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\tdata() {\n\t\tlinkCardsPageUid += 1\n\t\treturn {\n\t\t\tuid: linkCardsPageUid,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The config, from the flattened props or from `page.config`. Props win.\n\t\t *\n\t\t * @return {object} The config.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tconfig() {\n\t\t\tconst fromPage = (this.page && this.page.config) || {}\n\t\t\tconst fromProps = {}\n\t\t\tfor (const key of ['cards', 'categories', 'description', 'title', 'emptyLabel']) {\n\t\t\t\tif (this[key] !== null && this[key] !== undefined) {\n\t\t\t\t\tfromProps[key] = this[key]\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn { ...fromPage, ...fromProps }\n\t\t},\n\n\t\t/**\n\t\t * The heading, translated.\n\t\t *\n\t\t * @return {string} The title, or '' when the page declares none.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tresolvedTitle() {\n\t\t\treturn this.tr(this.config.title || (this.page && this.page.title) || '')\n\t\t},\n\n\t\t/**\n\t\t * The lead paragraph, translated.\n\t\t *\n\t\t * @return {string} The description.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tresolvedDescription() {\n\t\t\treturn this.tr(this.config.description || '')\n\t\t},\n\n\t\t/**\n\t\t * The empty-state text.\n\t\t *\n\t\t * @return {string} The text.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tresolvedEmptyLabel() {\n\t\t\treturn this.config.emptyLabel ? this.tr(this.config.emptyLabel) : t('nextcloud-vue', 'Nothing to open here.')\n\t\t},\n\n\t\t/**\n\t\t * The cards a reader may see and can open, with text translated and\n\t\t * the link resolved. A card that is gated away, or has no target that\n\t\t * resolves, is left out: a card that does nothing is worse than none.\n\t\t *\n\t\t * @return {Array<object>} The cards.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link\n\t\t */\n\t\tvisibleCards() {\n\t\t\tconst declared = Array.isArray(this.config.cards) ? this.config.cards : []\n\t\t\tconst runtime = this.cnManifest?.runtime ?? null\n\t\t\treturn declared\n\t\t\t\t.filter((card) => card && card.id && card.label)\n\t\t\t\t.filter((card) => this.passesPermission(card) && this.passesVisibleIf(card, runtime))\n\t\t\t\t.map((card) => {\n\t\t\t\t\tconst target = this.targetOf(card)\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: String(card.id),\n\t\t\t\t\t\tlabel: this.tr(card.label),\n\t\t\t\t\t\tdescription: this.tr(card.description || ''),\n\t\t\t\t\t\tdescriptionId: `cn-link-card-desc-${this.uid}-${String(card.id).replace(/[^A-Za-z0-9_-]/g, '-')}`,\n\t\t\t\t\t\ticon: card.icon || '',\n\t\t\t\t\t\tcategory: card.category || '',\n\t\t\t\t\t\texternal: target.external,\n\t\t\t\t\t\tlocation: target.location,\n\t\t\t\t\t\turl: target.url,\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((card) => card.url !== '')\n\t\t},\n\n\t\t/**\n\t\t * The groups to draw: ungrouped cards first under no caption, then\n\t\t * one group per category in declaration order. A group without a\n\t\t * visible card is left out.\n\t\t *\n\t\t * @return {Array<{key: string, label: string, headingId: string, cards: Array<object>}>} The groups.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\tgroups() {\n\t\t\tconst declared = (this.config.categories && typeof this.config.categories === 'object') ? this.config.categories : {}\n\t\t\tconst keys = Object.keys(declared)\n\t\t\tconst loose = this.visibleCards.filter((card) => !keys.includes(card.category))\n\t\t\tconst groups = loose.length > 0 ? [{ key: '', label: '', headingId: '', cards: loose }] : []\n\t\t\tkeys.forEach((key, index) => {\n\t\t\t\tconst cards = this.visibleCards.filter((card) => card.category === key)\n\t\t\t\tif (cards.length > 0) {\n\t\t\t\t\tgroups.push({\n\t\t\t\t\t\tkey,\n\t\t\t\t\t\tlabel: this.tr(declared[key]),\n\t\t\t\t\t\theadingId: `cn-link-cards-group-${this.uid}-${index}`,\n\t\t\t\t\t\tcards,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t})\n\t\t\treturn groups\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Translate a manifest string through the app's translate function.\n\t\t *\n\t\t * @param {string} value The declared string.\n\t\t * @return {string} The translation, or the input unchanged.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards\n\t\t */\n\t\ttr(value) {\n\t\t\tif (typeof value !== 'string' || value === '') {\n\t\t\t\treturn value\n\t\t\t}\n\t\t\treturn (this.translate ?? this.cnTranslate)(value)\n\t\t},\n\n\t\t/**\n\t\t * Whether the user holds the card's `permission`. Same rule as the\n\t\t * menu: no permission declared, or no permissions known, allows.\n\t\t *\n\t\t * @param {object} card The card.\n\t\t * @return {boolean} True when the card may render.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions\n\t\t */\n\t\tpassesPermission(card) {\n\t\t\tconst held = Array.isArray(this.cnPermissions) ? this.cnPermissions : []\n\t\t\tif (!card.permission || held.length === 0) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn held.includes(card.permission)\n\t\t},\n\n\t\t/**\n\t\t * Evaluate a card's `visibleIf` the way CnAppNav evaluates a menu\n\t\t * entry's: `appInstalled` first, then dot-path predicates against\n\t\t * the manifest's `runtime`.\n\t\t *\n\t\t * @param {object} card The card.\n\t\t * @param {object|null} runtime `manifest.runtime`, or null.\n\t\t * @return {boolean} True when the card may render.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions\n\t\t */\n\t\tpassesVisibleIf(card, runtime) {\n\t\t\tconst condition = card.visibleIf\n\t\t\tif (!condition || typeof condition !== 'object') {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif (condition.appInstalled && !isAppInstalled(condition.appInstalled)) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\treturn passesContextPredicates(condition, runtime)\n\t\t},\n\n\t\t/**\n\t\t * Where a card goes. An `href` is an external URL. A `route` is a\n\t\t * route name, or a path when it starts with `/`, resolved by the\n\t\t * router so the link carries the app's base with or without\n\t\t * `/index.php`.\n\t\t *\n\t\t * @param {object} card The card.\n\t\t * @return {{external: boolean, location: object|null, url: string}} The target; `url` is '' when it cannot be resolved.\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link\n\t\t */\n\t\ttargetOf(card) {\n\t\t\tif (typeof card.route === 'string' && card.route !== '') {\n\t\t\t\tconst location = card.route.startsWith('/') ? { path: card.route } : { name: card.route }\n\t\t\t\tif (card.params && typeof card.params === 'object') {\n\t\t\t\t\tlocation.params = card.params\n\t\t\t\t}\n\t\t\t\tif (card.query && typeof card.query === 'object') {\n\t\t\t\t\tlocation.query = card.query\n\t\t\t\t}\n\t\t\t\treturn { external: false, location, url: resolveHref(location, this.$router) }\n\t\t\t}\n\t\t\tif (typeof card.href === 'string' && card.href !== '') {\n\t\t\t\treturn { external: true, location: null, url: card.href }\n\t\t\t}\n\t\t\treturn { external: false, location: null, url: '' }\n\t\t},\n\n\t\t/**\n\t\t * Route a plain click on an in-app card. A modified or middle click,\n\t\t * and every click on an external card, is left to the browser.\n\t\t *\n\t\t * @param {object} card The resolved card.\n\t\t * @param {MouseEvent} event The click.\n\t\t * @return {void}\n\t\t * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link\n\t\t */\n\t\topen(card, event) {\n\t\t\tif (card.external || !card.location) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfollowLinkClick(event, card.location, this.$router)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-link-cards-page {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: calc(6 * var(--default-grid-baseline));\n\tpadding: calc(5 * var(--default-grid-baseline));\n}\n\n/* Clear the Nextcloud navigation toggle button (44px wide, absolutely\n positioned at the left edge of .app-content) plus 12px breathing room, as\n CnPageHeader does. Only the header shifts; the cards keep the full width. */\n.cn-link-cards-page__header {\n\tpadding-inline-start: 56px;\n}\n\n.cn-link-cards-page__title {\n\tmargin: 0 0 var(--default-grid-baseline);\n}\n\n.cn-link-cards-page__description,\n.cn-link-cards-page__empty {\n\tcolor: var(--color-text-maxcontrast);\n\tmargin: 0;\n\tmax-width: 70ch;\n}\n\n.cn-link-cards-page__group {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: calc(3 * var(--default-grid-baseline));\n}\n\n.cn-link-cards-page__caption {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.85em;\n\tfont-weight: 600;\n\tletter-spacing: 0.06em;\n\tmargin: 0;\n\ttext-transform: uppercase;\n}\n\n.cn-link-cards-page__grid {\n\tdisplay: grid;\n\tgap: calc(3 * var(--default-grid-baseline));\n\tgrid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n\tlist-style: none;\n\tmargin: 0;\n\tpadding: 0;\n}\n\n.cn-link-cards-page__cell {\n\tdisplay: flex;\n}\n\n.cn-link-cards-page__card {\n\talign-items: flex-start;\n\tbackground: var(--color-main-background);\n\tborder: 1px solid var(--color-border-dark);\n\tborder-radius: var(--border-radius-large);\n\tcolor: var(--color-main-text);\n\tdisplay: flex;\n\tflex: 1;\n\tgap: calc(3 * var(--default-grid-baseline));\n\tmin-width: 0;\n\tpadding: calc(4 * var(--default-grid-baseline));\n\ttransition: background-color var(--animation-quick, 100ms) ease, border-color var(--animation-quick, 100ms) ease, box-shadow var(--animation-quick, 100ms) ease;\n}\n\n/* A card is a link, and it must not look like a line of prose. A theme may\n underline every link with `!important` (thematiq: `a { text-decoration:\n underline !important }`, specificity 0,0,1). Among `!important` rules the\n higher specificity wins, so this class rule has to carry it too; without it\n the label and the description both rendered underlined on a live Nextcloud. */\n.cn-link-cards-page__card,\n.cn-link-cards-page__card:hover,\n.cn-link-cards-page__card:focus,\n.cn-link-cards-page__card:active {\n\ttext-decoration: none !important;\n}\n\n/* Hover moves the whole card: border, background and a soft lift. */\n.cn-link-cards-page__card:hover {\n\tbackground: var(--color-background-hover);\n\tborder-color: var(--color-primary-element);\n\tbox-shadow: 0 2px 6px var(--color-box-shadow);\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t.cn-link-cards-page__card {\n\t\ttransition: none;\n\t}\n}\n\n.cn-link-cards-page__card:focus-visible {\n\tborder-color: var(--color-primary-element);\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n}\n\n.cn-link-cards-page__icon {\n\tcolor: var(--color-primary-element);\n\tflex: none;\n}\n\n.cn-link-cards-page__body {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: var(--default-grid-baseline);\n\tmin-width: 0;\n}\n\n/* The label is the card's title. Its colour is set here, on the span, because\n a theme may force its link colour on the `a` itself; an inherited link\n colour made the title read as a hyperlink. */\n.cn-link-cards-page__label {\n\tcolor: var(--color-main-text);\n\tfont-size: 1.05em;\n\tfont-weight: 700;\n\tline-height: 1.3;\n\toverflow-wrap: anywhere;\n}\n\n.cn-link-cards-page__card-description {\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.9em;\n\tline-height: 1.4;\n}\n</style>\n"],"names":["_openBlock","_createElementBlock","_toDisplayString","_renderList","_createElementVNode","_createBlock"],"mappings":"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"}
@@ -1,10 +1,12 @@
1
1
  import { getCurrentUser } from '@nextcloud/auth';
2
2
  import axios__default from '@nextcloud/axios';
3
+ import { getCapabilities } from '@nextcloud/capabilities';
3
4
  import { translate } from '@nextcloud/l10n';
4
5
  import { generateUrl, generateRemoteUrl } from '@nextcloud/router';
5
6
  import { NcActionButton, NcActionLink, NcActionSeparator, NcButton, NcCheckboxRadioSwitch, NcDateTime, NcEmptyContent, NcListItem, NcLoadingIcon } from '@nextcloud/vue';
6
7
  import Delete from 'vue-material-design-icons/Delete.vue';
7
8
  import Download from 'vue-material-design-icons/Download.vue';
9
+ import FileDocumentEditOutline from 'vue-material-design-icons/FileDocumentEditOutline.vue';
8
10
  import FileOutline from 'vue-material-design-icons/FileOutline.vue';
9
11
  import FolderOutline from 'vue-material-design-icons/FolderOutline.vue';
10
12
  import InformationOutline from 'vue-material-design-icons/InformationOutline.vue';
@@ -17,6 +19,23 @@ import { safeHref } from '../../utils/safeHref.js';
17
19
  import { resolveObjectFolder } from '../CnFilesBrowser/filesBrowser.js';
18
20
  import { prefixUrl, buildHeaders } from '../../utils/headers.js';
19
21
 
22
+ /**
23
+ * The mime types Nextcloud Office opens by default for the current person.
24
+ *
25
+ * richdocuments publishes its capabilities only to a person it lets use
26
+ * Office, so an empty list means no Office here, or none for them.
27
+ *
28
+ * @return {Array<string>} The mime types, or an empty list.
29
+ */
30
+ function readOfficeMimetypes() {
31
+ try {
32
+ const mimetypes = getCapabilities()?.richdocuments?.mimetypes;
33
+ return Array.isArray(mimetypes) ? mimetypes.filter((mime) => typeof mime === 'string') : []
34
+ } catch {
35
+ return []
36
+ }
37
+ }
38
+
20
39
  var script = {
21
40
  name: 'CnFilesTab',
22
41
 
@@ -33,6 +52,7 @@ var script = {
33
52
  NcLoadingIcon,
34
53
  Delete,
35
54
  Download,
55
+ FileDocumentEditOutline,
36
56
  FileOutline,
37
57
  FolderOutline,
38
58
  InformationOutline,
@@ -57,6 +77,13 @@ var script = {
57
77
  noFilesLabel: { type: String, default: () => translate('nextcloud-vue', 'No files attached') },
58
78
  /** Label for the open/view file action */
59
79
  openLabel: { type: String, default: () => translate('nextcloud-vue', 'Open') },
80
+ /**
81
+ * Label of the action that opens a document in Nextcloud Office through
82
+ * OpenRegister's Office page. Shown only when Office is there for this
83
+ * person and opens the file's type; the page opens it for editing or
84
+ * read-only by the object's own rule.
85
+ */
86
+ openInOfficeLabel: { type: String, default: () => translate('nextcloud-vue', 'Open in Office') },
60
87
  /** Label for the delete action */
61
88
  deleteLabel: { type: String, default: () => translate('nextcloud-vue', 'Delete') },
62
89
  /** What the files browser's root crumb reads; null shows the folder's own name, as the Files app does. */
@@ -142,6 +169,8 @@ var script = {
142
169
  * @type {HTMLInputElement|null}
143
170
  */
144
171
  fileInputEl: null,
172
+ /** The mime types Nextcloud Office opens for this person; empty without Office. */
173
+ officeMimetypes: readOfficeMimetypes(),
145
174
  }
146
175
  },
147
176
 
@@ -498,6 +527,30 @@ var script = {
498
527
  return `${this.apiBase}/files/${encodeURIComponent(String(file.id))}/download`
499
528
  },
500
529
 
530
+ /**
531
+ * Whether Nextcloud Office opens this file for this person.
532
+ *
533
+ * @param {object} file The file row.
534
+ * @return {boolean} true when the Office action applies.
535
+ */
536
+ officeOpens(file) {
537
+ const mime = file.type || file.mimetype || file.mimeType || '';
538
+ return Boolean(file.id) && mime !== '' && this.officeMimetypes.includes(mime)
539
+ },
540
+
541
+ /**
542
+ * OpenRegister's Office page for a file of this object, which checks the
543
+ * object rule and opens the document editable or read-only.
544
+ *
545
+ * @param {object} file The file row.
546
+ * @return {string} The url.
547
+ */
548
+ officeUrlFor(file) {
549
+ const appBase = /\/api\/?$/.test(this.apiBase) ? this.apiBase.replace(/\/api\/?$/, '') : '/apps/openregister';
550
+ const segments = [this.register, this.schema, this.objectId, String(file.id)].map((part) => encodeURIComponent(String(part)));
551
+ return prefixUrl(`${appBase}/office/${segments.join('/')}`)
552
+ },
553
+
501
554
  /**
502
555
  * The Files app's permalink for a file: opens the Files app at the
503
556
  * file's folder with the file selected.