@conduction/nextcloud-vue 2.60.0 → 2.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,517 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+
6
+ <template>
7
+ <div class="cn-link-cards-page" data-testid="cn-link-cards-page">
8
+ <div class="cn-link-cards-page__header">
9
+ <h2 v-if="resolvedTitle" class="cn-link-cards-page__title">
10
+ {{ resolvedTitle }}
11
+ </h2>
12
+ <p v-if="resolvedDescription" class="cn-link-cards-page__description">
13
+ {{ resolvedDescription }}
14
+ </p>
15
+ </div>
16
+
17
+ <p
18
+ v-if="groups.length === 0"
19
+ class="cn-link-cards-page__empty"
20
+ data-testid="cn-link-cards-empty">
21
+ {{ resolvedEmptyLabel }}
22
+ </p>
23
+
24
+ <section
25
+ v-for="group in groups"
26
+ :key="group.key"
27
+ class="cn-link-cards-page__group"
28
+ :aria-labelledby="group.label ? group.headingId : null"
29
+ :aria-label="group.label ? null : resolvedTitle || null"
30
+ data-testid="cn-link-cards-group"
31
+ :data-group="group.key">
32
+ <h3
33
+ v-if="group.label"
34
+ :id="group.headingId"
35
+ class="cn-link-cards-page__caption">
36
+ {{ group.label }}
37
+ </h3>
38
+ <ul class="cn-link-cards-page__grid">
39
+ <li v-for="card in group.cards" :key="card.id" class="cn-link-cards-page__cell">
40
+ <!-- A real <a href>, so the card can be middle-clicked, copied
41
+ and announced as a link. A plain click on an in-app card
42
+ is routed, which keeps the page from reloading. -->
43
+ <a
44
+ class="cn-link-cards-page__card"
45
+ data-testid="cn-link-card"
46
+ :data-card-id="card.id"
47
+ :href="card.url"
48
+ :target="card.external ? '_blank' : null"
49
+ :rel="card.external ? 'noopener noreferrer' : null"
50
+ :aria-describedby="card.description ? card.descriptionId : null"
51
+ @click="open(card, $event)">
52
+ <CnIcon
53
+ v-if="card.icon"
54
+ :name="card.icon"
55
+ :size="24"
56
+ class="cn-link-cards-page__icon"
57
+ aria-hidden="true" />
58
+ <span class="cn-link-cards-page__body">
59
+ <span class="cn-link-cards-page__label">{{ card.label }}</span>
60
+ <span
61
+ v-if="card.description"
62
+ :id="card.descriptionId"
63
+ class="cn-link-cards-page__card-description">
64
+ {{ card.description }}
65
+ </span>
66
+ </span>
67
+ </a>
68
+ </li>
69
+ </ul>
70
+ </section>
71
+ </div>
72
+ </template>
73
+
74
+ <script>
75
+ import { translate as t } from '@nextcloud/l10n'
76
+ import { isAppInstalled } from '../../utils/appInstalled.js'
77
+ import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
78
+ import { passesContextPredicates } from '../../utils/visibleIfContext.js'
79
+ import { CnIcon } from '../CnIcon/index.js'
80
+
81
+ let linkCardsPageUid = 0
82
+
83
+ /**
84
+ * CnLinkCardsPage: the `type: "links"` page. Link cards, grouped under captions.
85
+ *
86
+ * One page that opens every page a short menu leaves out. A card has a
87
+ * label, a one-line description, an optional icon and a target. Groups come
88
+ * from `categories`, in the order they are declared; a card names its group
89
+ * with `category`.
90
+ *
91
+ * ```json
92
+ * { "id": "Modules", "route": "/modules", "type": "links", "title": "Modules and more",
93
+ * "config": {
94
+ * "description": "Every page that is not in the daily menu.",
95
+ * "categories": { "sales": "Sales", "marketing": "Marketing" },
96
+ * "cards": [
97
+ * { "id": "Leads", "label": "Leads", "description": "Every deal you are working on.",
98
+ * "icon": "CashMultiple", "category": "sales", "route": "Leads" }
99
+ * ]
100
+ * } }
101
+ * ```
102
+ *
103
+ * Every string goes through the app's translate function. A card is hidden
104
+ * by the same `visibleIf` and `permission` a menu entry takes, and a group
105
+ * with no visible card is not drawn.
106
+ */
107
+ export default {
108
+ name: 'CnLinkCardsPage',
109
+
110
+ components: { CnIcon },
111
+
112
+ inject: {
113
+ /** The app's translate function, provided by CnAppRoot. Identity outside one. */
114
+ cnTranslate: { default: () => (key) => key },
115
+ /** The effective manifest, for `visibleIf` predicates against `runtime`. */
116
+ cnManifest: { default: null },
117
+ /** The permissions the user holds. Empty means the app did not say, and allows. */
118
+ cnPermissions: { default: () => [] },
119
+ },
120
+
121
+ props: {
122
+ /** The whole manifest page, for a host that mounts this component itself. CnPageRenderer passes the flattened props below instead. */
123
+ page: {
124
+ type: Object,
125
+ default: () => ({}),
126
+ },
127
+
128
+ /**
129
+ * The cards: `{ id, label, description?, icon?, category?, route?, params?, query?, href?, visibleIf?, permission? }`.
130
+ * `route` is a route name, or a path when it starts with `/`.
131
+ *
132
+ * @type {Array<object>|null}
133
+ */
134
+ cards: {
135
+ type: Array,
136
+ default: null,
137
+ },
138
+
139
+ /**
140
+ * Group key to caption. Sets the order of the groups.
141
+ *
142
+ * @type {Record<string, string>|null}
143
+ */
144
+ categories: {
145
+ type: Object,
146
+ default: null,
147
+ },
148
+
149
+ /** The heading. */
150
+ title: {
151
+ type: String,
152
+ default: null,
153
+ },
154
+
155
+ /** The lead paragraph under the heading. */
156
+ description: {
157
+ type: String,
158
+ default: null,
159
+ },
160
+
161
+ /** Text shown when no card is visible. */
162
+ emptyLabel: {
163
+ type: String,
164
+ default: null,
165
+ },
166
+
167
+ /**
168
+ * Translate function. Falls back to the injected `cnTranslate`.
169
+ *
170
+ * @type {((key: string) => string)|null}
171
+ */
172
+ translate: {
173
+ type: Function,
174
+ default: null,
175
+ },
176
+ },
177
+
178
+ data() {
179
+ linkCardsPageUid += 1
180
+ return {
181
+ uid: linkCardsPageUid,
182
+ }
183
+ },
184
+
185
+ computed: {
186
+ /**
187
+ * The config, from the flattened props or from `page.config`. Props win.
188
+ *
189
+ * @return {object} The config.
190
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
191
+ */
192
+ config() {
193
+ const fromPage = (this.page && this.page.config) || {}
194
+ const fromProps = {}
195
+ for (const key of ['cards', 'categories', 'description', 'title', 'emptyLabel']) {
196
+ if (this[key] !== null && this[key] !== undefined) {
197
+ fromProps[key] = this[key]
198
+ }
199
+ }
200
+ return { ...fromPage, ...fromProps }
201
+ },
202
+
203
+ /**
204
+ * The heading, translated.
205
+ *
206
+ * @return {string} The title, or '' when the page declares none.
207
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
208
+ */
209
+ resolvedTitle() {
210
+ return this.tr(this.config.title || (this.page && this.page.title) || '')
211
+ },
212
+
213
+ /**
214
+ * The lead paragraph, translated.
215
+ *
216
+ * @return {string} The description.
217
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
218
+ */
219
+ resolvedDescription() {
220
+ return this.tr(this.config.description || '')
221
+ },
222
+
223
+ /**
224
+ * The empty-state text.
225
+ *
226
+ * @return {string} The text.
227
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
228
+ */
229
+ resolvedEmptyLabel() {
230
+ return this.config.emptyLabel ? this.tr(this.config.emptyLabel) : t('nextcloud-vue', 'Nothing to open here.')
231
+ },
232
+
233
+ /**
234
+ * The cards a reader may see and can open, with text translated and
235
+ * the link resolved. A card that is gated away, or has no target that
236
+ * resolves, is left out: a card that does nothing is worse than none.
237
+ *
238
+ * @return {Array<object>} The cards.
239
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link
240
+ */
241
+ visibleCards() {
242
+ const declared = Array.isArray(this.config.cards) ? this.config.cards : []
243
+ const runtime = this.cnManifest?.runtime ?? null
244
+ return declared
245
+ .filter((card) => card && card.id && card.label)
246
+ .filter((card) => this.passesPermission(card) && this.passesVisibleIf(card, runtime))
247
+ .map((card) => {
248
+ const target = this.targetOf(card)
249
+ return {
250
+ id: String(card.id),
251
+ label: this.tr(card.label),
252
+ description: this.tr(card.description || ''),
253
+ descriptionId: `cn-link-card-desc-${this.uid}-${String(card.id).replace(/[^A-Za-z0-9_-]/g, '-')}`,
254
+ icon: card.icon || '',
255
+ category: card.category || '',
256
+ external: target.external,
257
+ location: target.location,
258
+ url: target.url,
259
+ }
260
+ })
261
+ .filter((card) => card.url !== '')
262
+ },
263
+
264
+ /**
265
+ * The groups to draw: ungrouped cards first under no caption, then
266
+ * one group per category in declaration order. A group without a
267
+ * visible card is left out.
268
+ *
269
+ * @return {Array<{key: string, label: string, headingId: string, cards: Array<object>}>} The groups.
270
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
271
+ */
272
+ groups() {
273
+ const declared = (this.config.categories && typeof this.config.categories === 'object') ? this.config.categories : {}
274
+ const keys = Object.keys(declared)
275
+ const loose = this.visibleCards.filter((card) => !keys.includes(card.category))
276
+ const groups = loose.length > 0 ? [{ key: '', label: '', headingId: '', cards: loose }] : []
277
+ keys.forEach((key, index) => {
278
+ const cards = this.visibleCards.filter((card) => card.category === key)
279
+ if (cards.length > 0) {
280
+ groups.push({
281
+ key,
282
+ label: this.tr(declared[key]),
283
+ headingId: `cn-link-cards-group-${this.uid}-${index}`,
284
+ cards,
285
+ })
286
+ }
287
+ })
288
+ return groups
289
+ },
290
+ },
291
+
292
+ methods: {
293
+ /**
294
+ * Translate a manifest string through the app's translate function.
295
+ *
296
+ * @param {string} value The declared string.
297
+ * @return {string} The translation, or the input unchanged.
298
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-links-page-renders-grouped-link-cards
299
+ */
300
+ tr(value) {
301
+ if (typeof value !== 'string' || value === '') {
302
+ return value
303
+ }
304
+ return (this.translate ?? this.cnTranslate)(value)
305
+ },
306
+
307
+ /**
308
+ * Whether the user holds the card's `permission`. Same rule as the
309
+ * menu: no permission declared, or no permissions known, allows.
310
+ *
311
+ * @param {object} card The card.
312
+ * @return {boolean} True when the card may render.
313
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions
314
+ */
315
+ passesPermission(card) {
316
+ const held = Array.isArray(this.cnPermissions) ? this.cnPermissions : []
317
+ if (!card.permission || held.length === 0) {
318
+ return true
319
+ }
320
+ return held.includes(card.permission)
321
+ },
322
+
323
+ /**
324
+ * Evaluate a card's `visibleIf` the way CnAppNav evaluates a menu
325
+ * entry's: `appInstalled` first, then dot-path predicates against
326
+ * the manifest's `runtime`.
327
+ *
328
+ * @param {object} card The card.
329
+ * @param {object|null} runtime `manifest.runtime`, or null.
330
+ * @return {boolean} True when the card may render.
331
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-cards-honour-menu-visibility-conditions
332
+ */
333
+ passesVisibleIf(card, runtime) {
334
+ const condition = card.visibleIf
335
+ if (!condition || typeof condition !== 'object') {
336
+ return true
337
+ }
338
+ if (condition.appInstalled && !isAppInstalled(condition.appInstalled)) {
339
+ return false
340
+ }
341
+ return passesContextPredicates(condition, runtime)
342
+ },
343
+
344
+ /**
345
+ * Where a card goes. An `href` is an external URL. A `route` is a
346
+ * route name, or a path when it starts with `/`, resolved by the
347
+ * router so the link carries the app's base with or without
348
+ * `/index.php`.
349
+ *
350
+ * @param {object} card The card.
351
+ * @return {{external: boolean, location: object|null, url: string}} The target; `url` is '' when it cannot be resolved.
352
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link
353
+ */
354
+ targetOf(card) {
355
+ if (typeof card.route === 'string' && card.route !== '') {
356
+ const location = card.route.startsWith('/') ? { path: card.route } : { name: card.route }
357
+ if (card.params && typeof card.params === 'object') {
358
+ location.params = card.params
359
+ }
360
+ if (card.query && typeof card.query === 'object') {
361
+ location.query = card.query
362
+ }
363
+ return { external: false, location, url: resolveHref(location, this.$router) }
364
+ }
365
+ if (typeof card.href === 'string' && card.href !== '') {
366
+ return { external: true, location: null, url: card.href }
367
+ }
368
+ return { external: false, location: null, url: '' }
369
+ },
370
+
371
+ /**
372
+ * Route a plain click on an in-app card. A modified or middle click,
373
+ * and every click on an external card, is left to the browser.
374
+ *
375
+ * @param {object} card The resolved card.
376
+ * @param {MouseEvent} event The click.
377
+ * @return {void}
378
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-card-is-a-real-link
379
+ */
380
+ open(card, event) {
381
+ if (card.external || !card.location) {
382
+ return
383
+ }
384
+ followLinkClick(event, card.location, this.$router)
385
+ },
386
+ },
387
+ }
388
+ </script>
389
+
390
+ <style scoped>
391
+ .cn-link-cards-page {
392
+ display: flex;
393
+ flex-direction: column;
394
+ gap: calc(6 * var(--default-grid-baseline));
395
+ padding: calc(5 * var(--default-grid-baseline));
396
+ }
397
+
398
+ /* Clear the Nextcloud navigation toggle button (44px wide, absolutely
399
+ positioned at the left edge of .app-content) plus 12px breathing room, as
400
+ CnPageHeader does. Only the header shifts; the cards keep the full width. */
401
+ .cn-link-cards-page__header {
402
+ padding-inline-start: 56px;
403
+ }
404
+
405
+ .cn-link-cards-page__title {
406
+ margin: 0 0 var(--default-grid-baseline);
407
+ }
408
+
409
+ .cn-link-cards-page__description,
410
+ .cn-link-cards-page__empty {
411
+ color: var(--color-text-maxcontrast);
412
+ margin: 0;
413
+ max-width: 70ch;
414
+ }
415
+
416
+ .cn-link-cards-page__group {
417
+ display: flex;
418
+ flex-direction: column;
419
+ gap: calc(3 * var(--default-grid-baseline));
420
+ }
421
+
422
+ .cn-link-cards-page__caption {
423
+ color: var(--color-text-maxcontrast);
424
+ font-size: 0.85em;
425
+ font-weight: 600;
426
+ letter-spacing: 0.06em;
427
+ margin: 0;
428
+ text-transform: uppercase;
429
+ }
430
+
431
+ .cn-link-cards-page__grid {
432
+ display: grid;
433
+ gap: calc(3 * var(--default-grid-baseline));
434
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
435
+ list-style: none;
436
+ margin: 0;
437
+ padding: 0;
438
+ }
439
+
440
+ .cn-link-cards-page__cell {
441
+ display: flex;
442
+ }
443
+
444
+ .cn-link-cards-page__card {
445
+ align-items: flex-start;
446
+ background: var(--color-main-background);
447
+ border: 1px solid var(--color-border-dark);
448
+ border-radius: var(--border-radius-large);
449
+ color: var(--color-main-text);
450
+ display: flex;
451
+ flex: 1;
452
+ gap: calc(3 * var(--default-grid-baseline));
453
+ min-width: 0;
454
+ padding: calc(4 * var(--default-grid-baseline));
455
+ transition: background-color var(--animation-quick, 100ms) ease, border-color var(--animation-quick, 100ms) ease, box-shadow var(--animation-quick, 100ms) ease;
456
+ }
457
+
458
+ /* A card is a link, and it must not look like a line of prose. A theme may
459
+ underline every link with `!important` (thematiq: `a { text-decoration:
460
+ underline !important }`, specificity 0,0,1). Among `!important` rules the
461
+ higher specificity wins, so this class rule has to carry it too; without it
462
+ the label and the description both rendered underlined on a live Nextcloud. */
463
+ .cn-link-cards-page__card,
464
+ .cn-link-cards-page__card:hover,
465
+ .cn-link-cards-page__card:focus,
466
+ .cn-link-cards-page__card:active {
467
+ text-decoration: none !important;
468
+ }
469
+
470
+ /* Hover moves the whole card: border, background and a soft lift. */
471
+ .cn-link-cards-page__card:hover {
472
+ background: var(--color-background-hover);
473
+ border-color: var(--color-primary-element);
474
+ box-shadow: 0 2px 6px var(--color-box-shadow);
475
+ }
476
+
477
+ @media (prefers-reduced-motion: reduce) {
478
+ .cn-link-cards-page__card {
479
+ transition: none;
480
+ }
481
+ }
482
+
483
+ .cn-link-cards-page__card:focus-visible {
484
+ border-color: var(--color-primary-element);
485
+ outline: 2px solid var(--color-primary-element);
486
+ outline-offset: 2px;
487
+ }
488
+
489
+ .cn-link-cards-page__icon {
490
+ color: var(--color-primary-element);
491
+ flex: none;
492
+ }
493
+
494
+ .cn-link-cards-page__body {
495
+ display: flex;
496
+ flex-direction: column;
497
+ gap: var(--default-grid-baseline);
498
+ min-width: 0;
499
+ }
500
+
501
+ /* The label is the card's title. Its colour is set here, on the span, because
502
+ a theme may force its link colour on the `a` itself; an inherited link
503
+ colour made the title read as a hyperlink. */
504
+ .cn-link-cards-page__label {
505
+ color: var(--color-main-text);
506
+ font-size: 1.05em;
507
+ font-weight: 700;
508
+ line-height: 1.3;
509
+ overflow-wrap: anywhere;
510
+ }
511
+
512
+ .cn-link-cards-page__card-description {
513
+ color: var(--color-text-maxcontrast);
514
+ font-size: 0.9em;
515
+ line-height: 1.4;
516
+ }
517
+ </style>
@@ -0,0 +1,4 @@
1
+ import CnLinkCardsPage from './CnLinkCardsPage.vue'
2
+
3
+ export default CnLinkCardsPage
4
+ export { CnLinkCardsPage }
@@ -130,6 +130,18 @@
130
130
  </template>
131
131
  {{ openLabel }}
132
132
  </NcActionButton>
133
+ <!-- OpenRegister's Office page applies the object rule:
134
+ update edits, read views, no read is a 404. -->
135
+ <NcActionLink
136
+ v-if="officeOpens(file)"
137
+ :href="officeUrlFor(file)"
138
+ target="_blank"
139
+ :closeAfterClick="true">
140
+ <template #icon>
141
+ <FileDocumentEditOutline :size="20" />
142
+ </template>
143
+ {{ openInOfficeLabel }}
144
+ </NcActionLink>
133
145
  <NcActionLink
134
146
  v-if="file.id"
135
147
  :href="downloadUrlFor(file)"
@@ -194,6 +206,7 @@
194
206
  <script>
195
207
  import { getCurrentUser } from '@nextcloud/auth'
196
208
  import axios from '@nextcloud/axios'
209
+ import { getCapabilities } from '@nextcloud/capabilities'
197
210
  import { translate as t } from '@nextcloud/l10n'
198
211
  import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
199
212
  import {
@@ -209,6 +222,7 @@ import {
209
222
  } from '@nextcloud/vue'
210
223
  import Delete from 'vue-material-design-icons/Delete.vue'
211
224
  import Download from 'vue-material-design-icons/Download.vue'
225
+ import FileDocumentEditOutline from 'vue-material-design-icons/FileDocumentEditOutline.vue'
212
226
  import FileOutline from 'vue-material-design-icons/FileOutline.vue'
213
227
  import FolderOutline from 'vue-material-design-icons/FolderOutline.vue'
214
228
  import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
@@ -221,6 +235,23 @@ import { buildHeaders, prefixUrl } from '../../utils/index.js'
221
235
  import { safeHref } from '../../utils/safeHref.js'
222
236
  import { resolveObjectFolder } from '../CnFilesBrowser/filesBrowser.js'
223
237
 
238
+ /**
239
+ * The mime types Nextcloud Office opens by default for the current person.
240
+ *
241
+ * richdocuments publishes its capabilities only to a person it lets use
242
+ * Office, so an empty list means no Office here, or none for them.
243
+ *
244
+ * @return {Array<string>} The mime types, or an empty list.
245
+ */
246
+ function readOfficeMimetypes() {
247
+ try {
248
+ const mimetypes = getCapabilities()?.richdocuments?.mimetypes
249
+ return Array.isArray(mimetypes) ? mimetypes.filter((mime) => typeof mime === 'string') : []
250
+ } catch {
251
+ return []
252
+ }
253
+ }
254
+
224
255
  export default {
225
256
  name: 'CnFilesTab',
226
257
 
@@ -237,6 +268,7 @@ export default {
237
268
  NcLoadingIcon,
238
269
  Delete,
239
270
  Download,
271
+ FileDocumentEditOutline,
240
272
  FileOutline,
241
273
  FolderOutline,
242
274
  InformationOutline,
@@ -261,6 +293,13 @@ export default {
261
293
  noFilesLabel: { type: String, default: () => t('nextcloud-vue', 'No files attached') },
262
294
  /** Label for the open/view file action */
263
295
  openLabel: { type: String, default: () => t('nextcloud-vue', 'Open') },
296
+ /**
297
+ * Label of the action that opens a document in Nextcloud Office through
298
+ * OpenRegister's Office page. Shown only when Office is there for this
299
+ * person and opens the file's type; the page opens it for editing or
300
+ * read-only by the object's own rule.
301
+ */
302
+ openInOfficeLabel: { type: String, default: () => t('nextcloud-vue', 'Open in Office') },
264
303
  /** Label for the delete action */
265
304
  deleteLabel: { type: String, default: () => t('nextcloud-vue', 'Delete') },
266
305
  /** What the files browser's root crumb reads; null shows the folder's own name, as the Files app does. */
@@ -346,6 +385,8 @@ export default {
346
385
  * @type {HTMLInputElement|null}
347
386
  */
348
387
  fileInputEl: null,
388
+ /** The mime types Nextcloud Office opens for this person; empty without Office. */
389
+ officeMimetypes: readOfficeMimetypes(),
349
390
  }
350
391
  },
351
392
 
@@ -702,6 +743,30 @@ export default {
702
743
  return `${this.apiBase}/files/${encodeURIComponent(String(file.id))}/download`
703
744
  },
704
745
 
746
+ /**
747
+ * Whether Nextcloud Office opens this file for this person.
748
+ *
749
+ * @param {object} file The file row.
750
+ * @return {boolean} true when the Office action applies.
751
+ */
752
+ officeOpens(file) {
753
+ const mime = file.type || file.mimetype || file.mimeType || ''
754
+ return Boolean(file.id) && mime !== '' && this.officeMimetypes.includes(mime)
755
+ },
756
+
757
+ /**
758
+ * OpenRegister's Office page for a file of this object, which checks the
759
+ * object rule and opens the document editable or read-only.
760
+ *
761
+ * @param {object} file The file row.
762
+ * @return {string} The url.
763
+ */
764
+ officeUrlFor(file) {
765
+ const appBase = /\/api\/?$/.test(this.apiBase) ? this.apiBase.replace(/\/api\/?$/, '') : '/apps/openregister'
766
+ const segments = [this.register, this.schema, this.objectId, String(file.id)].map((part) => encodeURIComponent(String(part)))
767
+ return prefixUrl(`${appBase}/office/${segments.join('/')}`)
768
+ },
769
+
705
770
  /**
706
771
  * The Files app's permalink for a file: opens the Files app at the
707
772
  * file's folder with the file selected.
@@ -57,6 +57,7 @@ export const defaultPageTypes = {
57
57
  map: defineAsyncComponent(() => import('../CnMapPage/CnMapPage.vue').then((m) => m.default)),
58
58
  roadmap: defineAsyncComponent(() => import('../CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue').then((m) => m.default)),
59
59
  reports: defineAsyncComponent(() => import('../CnReportsPage/CnReportsPage.vue').then((m) => m.default)),
60
+ links: defineAsyncComponent(() => import('../CnLinkCardsPage/CnLinkCardsPage.vue').then((m) => m.default)),
60
61
  store: defineAsyncComponent(() => import('../CnStorePage/CnStorePage.vue').then((m) => m.default)),
61
62
  search: defineAsyncComponent(() => import('../CnSearchPage/CnSearchPage.vue').then((m) => m.default)),
62
63
  wiki: defineAsyncComponent(() => import('../CnWikiPage/CnWikiPage.vue').then((m) => m.default)),
@@ -436,6 +436,13 @@ export default {
436
436
  padding: 16px;
437
437
  }
438
438
 
439
+ /* Clear the Nextcloud navigation toggle button (44px wide, absolutely
440
+ positioned at the left edge of .app-content) plus 12px breathing room, as
441
+ CnPageHeader does. Only the header shifts; the body keeps the full width. */
442
+ .cn-reports-page__header {
443
+ padding-inline-start: 56px;
444
+ }
445
+
439
446
  .cn-reports-page__title {
440
447
  margin: 0 0 4px;
441
448
  }
@@ -774,6 +774,13 @@ export default {
774
774
  max-width: 1200px;
775
775
  }
776
776
 
777
+ /* Clear the Nextcloud navigation toggle button (44px wide, absolutely
778
+ positioned at the left edge of .app-content) plus 12px breathing room, as
779
+ CnPageHeader does. Only the header shifts; the body keeps the full width. */
780
+ .cn-store-page__header {
781
+ padding-inline-start: 56px;
782
+ }
783
+
777
784
  .cn-store-page__heading {
778
785
  display: flex;
779
786
  flex-wrap: wrap;
@@ -418,6 +418,13 @@ export default {
418
418
  margin-bottom: 16px;
419
419
  }
420
420
 
421
+ /* Without a sidebar the title starts at the left edge of the content, under
422
+ the Nextcloud navigation toggle button (44px wide). Clear it as CnPageHeader
423
+ does. With a sidebar the tree sits there instead and the title is clear. */
424
+ .cn-wiki-page:not(.cn-wiki-page--has-sidebar) .cn-wiki-page__header {
425
+ padding-inline-start: 56px;
426
+ }
427
+
421
428
  .cn-wiki-page__title {
422
429
  font-size: 1.6em;
423
430
  margin: 0;