@conduction/nextcloud-vue 2.56.0-beta.45 → 2.56.0-beta.47

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 (149) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +57 -4
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +44 -17
  4. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  5. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  6. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +44 -2
  7. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  8. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +140 -59
  9. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +20 -6
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +3 -2
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +10 -39
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +5 -3
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  22. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +6 -4
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +46 -0
  26. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js +6 -2
  28. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  30. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +82 -9
  31. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js +104 -90
  33. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  34. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +9 -0
  35. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js.map +1 -0
  36. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +355 -0
  37. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -0
  38. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +462 -0
  39. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -0
  40. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +39 -10
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +418 -399
  44. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  46. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  47. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +1 -1
  48. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +156 -3
  49. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +168 -52
  51. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js +9 -0
  53. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js.map +1 -0
  54. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js +83 -0
  55. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js.map +1 -0
  56. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js +119 -0
  57. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js.map +1 -0
  58. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
  59. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +22 -1
  60. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
  61. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +20 -14
  62. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
  63. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue.js +1 -1
  64. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +2 -0
  65. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  66. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +15 -5
  67. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  68. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue.js +1 -1
  69. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js +8 -30
  70. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js.map +1 -1
  71. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue3.js.map +1 -1
  72. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  73. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -2
  74. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  75. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  76. package/dist/esm/components/CnTextWidget/CnTextWidget.vue.js +1 -1
  77. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js +63 -4
  78. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js.map +1 -1
  79. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js +19 -2
  80. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js.map +1 -1
  81. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +9 -0
  82. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js.map +1 -0
  83. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +164 -0
  84. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -0
  85. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +93 -0
  86. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -0
  87. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  88. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +12 -0
  89. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  90. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  91. package/dist/esm/l10n/en.json.js +45 -1
  92. package/dist/esm/l10n/en.json.js.map +1 -1
  93. package/dist/esm/l10n/nl.json.js +44 -1
  94. package/dist/esm/l10n/nl.json.js.map +1 -1
  95. package/dist/esm/library-style.js +1 -1
  96. package/dist/esm/nextcloud-vue.css +1182 -328
  97. package/dist/esm/utils/boardDate.js +35 -1
  98. package/dist/esm/utils/boardDate.js.map +1 -1
  99. package/dist/esm/utils/featureCatalog.js +135 -0
  100. package/dist/esm/utils/featureCatalog.js.map +1 -0
  101. package/dist/esm/utils/fillTemplate.js +84 -4
  102. package/dist/esm/utils/fillTemplate.js.map +1 -1
  103. package/dist/esm/utils/headerMeta.js +62 -12
  104. package/dist/esm/utils/headerMeta.js.map +1 -1
  105. package/dist/esm/utils/roadmapIssues.js +86 -0
  106. package/dist/esm/utils/roadmapIssues.js.map +1 -0
  107. package/dist/esm/utils/userDisplayName.js +47 -0
  108. package/dist/esm/utils/userDisplayName.js.map +1 -0
  109. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  110. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  111. package/dist/nextcloud-vue.cjs.js +15618 -13097
  112. package/dist/nextcloud-vue.cjs.js.map +2 -2
  113. package/dist/nextcloud-vue.css +1182 -328
  114. package/l10n/en.json +45 -1
  115. package/l10n/nl.json +44 -1
  116. package/package.json +1 -1
  117. package/src/components/CnAppRoot/CnAppRoot.md +2 -0
  118. package/src/components/CnAppRoot/CnAppRoot.vue +66 -5
  119. package/src/components/CnChoiceCards/CnChoiceCards.md +27 -0
  120. package/src/components/CnChoiceCards/CnChoiceCards.vue +168 -3
  121. package/src/components/CnDashboardPage/CnDashboardPage.vue +45 -6
  122. package/src/components/CnDataTable/CnDataTable.vue +10 -39
  123. package/src/components/CnDetailPage/CnDetailPage.vue +5 -3
  124. package/src/components/CnFactsWidget/CnFactsWidget.vue +6 -4
  125. package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +51 -1
  126. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +98 -13
  127. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +1053 -0
  128. package/src/components/CnFormDialog/CnFormDialog.md +1 -0
  129. package/src/components/CnFormDialog/CnFormDialog.vue +619 -562
  130. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +338 -5
  131. package/src/components/CnModuleCard/CnModuleCard.vue +258 -0
  132. package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +23 -2
  133. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +5 -0
  134. package/src/components/CnRoadmapTab/CnRoadmapTab.vue +6 -28
  135. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -2
  136. package/src/components/CnTextWidget/CnTextWidget.vue +89 -4
  137. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +305 -0
  138. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +12 -0
  139. package/src/css/dialog.css +47 -0
  140. package/src/css/look-board-detail.css +8 -0
  141. package/src/css/look-board.css +33 -0
  142. package/src/schemas/app-manifest-v2.schema.json +67 -4
  143. package/src/utils/boardDate.js +34 -0
  144. package/src/utils/featureCatalog.js +132 -0
  145. package/src/utils/fillTemplate.js +83 -3
  146. package/src/utils/headerMeta.js +62 -12
  147. package/src/utils/roadmapIssues.js +82 -0
  148. package/src/utils/userDisplayName.js +54 -0
  149. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -14,8 +14,34 @@
14
14
  </p>
15
15
  </div>
16
16
 
17
+ <section
18
+ v-if="visibleModules.length > 0"
19
+ class="cn-link-cards-page__modules"
20
+ :aria-labelledby="`cn-link-cards-modules-${uid}`"
21
+ data-testid="cn-link-cards-modules">
22
+ <div class="cn-link-cards-page__modules-head">
23
+ <h3 :id="`cn-link-cards-modules-${uid}`" class="cn-link-cards-page__modules-title">
24
+ {{ resolvedModulesTitle }}
25
+ </h3>
26
+ <span class="cn-link-cards-page__modules-count" data-testid="cn-link-cards-modules-count">
27
+ {{ modulesCount }}
28
+ </span>
29
+ </div>
30
+ <div class="cn-link-cards-page__modules-grid">
31
+ <CnModuleCard
32
+ v-for="module in visibleModules"
33
+ :key="module.id"
34
+ :module="module"
35
+ :headingId="`cn-link-cards-module-${uid}-${module.key}`" />
36
+ </div>
37
+ <p v-if="resolvedFootnote" class="cn-link-cards-page__modules-note" data-testid="cn-link-cards-modules-note">
38
+ <InformationOutline :size="18" class="cn-link-cards-page__modules-note-icon" />
39
+ <span>{{ resolvedFootnote }}</span>
40
+ </p>
41
+ </section>
42
+
17
43
  <p
18
- v-if="groups.length === 0"
44
+ v-if="groups.length === 0 && visibleModules.length === 0"
19
45
  class="cn-link-cards-page__empty"
20
46
  data-testid="cn-link-cards-empty">
21
47
  {{ resolvedEmptyLabel }}
@@ -25,6 +51,7 @@
25
51
  v-for="group in groups"
26
52
  :key="group.key"
27
53
  class="cn-link-cards-page__group"
54
+ :class="{ 'cn-link-cards-page__group--list': isListStyle }"
28
55
  :aria-labelledby="group.label ? group.headingId : null"
29
56
  :aria-label="group.label ? null : resolvedTitle || null"
30
57
  data-testid="cn-link-cards-group"
@@ -35,7 +62,38 @@
35
62
  class="cn-link-cards-page__caption">
36
63
  {{ group.label }}
37
64
  </h3>
38
- <ul class="cn-link-cards-page__grid">
65
+ <!-- groupStyle "list": the group is one card of rows, each an icon
66
+ chip, the label over its description, and a trailing chevron
67
+ (an external-link icon for a URL). -->
68
+ <ul v-if="isListStyle" class="cn-link-cards-page__list">
69
+ <li v-for="card in group.cards" :key="card.id">
70
+ <a
71
+ class="cn-link-cards-page__row"
72
+ data-testid="cn-link-card"
73
+ :data-card-id="card.id"
74
+ :href="card.url"
75
+ :target="card.external ? '_blank' : null"
76
+ :rel="card.external ? 'noopener noreferrer' : null"
77
+ :aria-describedby="card.description ? card.descriptionId : null"
78
+ @click="open(card, $event)">
79
+ <span v-if="card.icon" class="cn-link-cards-page__row-icon" aria-hidden="true">
80
+ <CnIcon :name="card.icon" :size="20" />
81
+ </span>
82
+ <span class="cn-link-cards-page__row-body">
83
+ <span class="cn-link-cards-page__row-label">{{ card.label }}</span>
84
+ <span
85
+ v-if="card.description"
86
+ :id="card.descriptionId"
87
+ class="cn-link-cards-page__row-description">
88
+ {{ card.description }}
89
+ </span>
90
+ </span>
91
+ <OpenInNew v-if="trailingOf(card) === 'external'" :size="18" class="cn-link-cards-page__row-trailing" />
92
+ <ChevronRight v-else :size="20" class="cn-link-cards-page__row-trailing" />
93
+ </a>
94
+ </li>
95
+ </ul>
96
+ <ul v-else class="cn-link-cards-page__grid">
39
97
  <li v-for="card in group.cards" :key="card.id" class="cn-link-cards-page__cell">
40
98
  <!-- A real <a href>, so the card can be middle-clicked, copied
41
99
  and announced as a link. A plain click on an in-app card
@@ -72,7 +130,11 @@
72
130
  </template>
73
131
 
74
132
  <script>
75
- import { translate as t } from '@nextcloud/l10n'
133
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
134
+ import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
135
+ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
136
+ import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
137
+ import CnModuleCard from '../CnModuleCard/CnModuleCard.vue'
76
138
  import { isAppInstalled } from '../../utils/appInstalled.js'
77
139
  import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
78
140
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
@@ -107,7 +169,7 @@ let linkCardsPageUid = 0
107
169
  export default {
108
170
  name: 'CnLinkCardsPage',
109
171
 
110
- components: { CnIcon },
172
+ components: { ChevronRight, CnIcon, CnModuleCard, InformationOutline, OpenInNew },
111
173
 
112
174
  inject: {
113
175
  /** The app's translate function, provided by CnAppRoot. Identity outside one. */
@@ -158,6 +220,37 @@ export default {
158
220
  default: null,
159
221
  },
160
222
 
223
+ /**
224
+ * The modules: `{ id, label, description?, icon?, enabled?, enabledIf?,
225
+ * route?, pages: [{ label, route, highlight? }], settings?: { label?, prefix?,
226
+ * route?, href?, permission?, visibleIf? }, permission?, visibleIf? }`.
227
+ * Drawn as module cards above the link cards.
228
+ *
229
+ * @type {Array<object>|null}
230
+ */
231
+ modules: {
232
+ type: Array,
233
+ default: null,
234
+ },
235
+
236
+ /** `cards` (default): a grid of link cards per group. `list`: one card per group with a row per link. */
237
+ groupStyle: {
238
+ type: String,
239
+ default: null,
240
+ },
241
+
242
+ /** Heading of the modules section. Defaults to "Modules". */
243
+ modulesTitle: {
244
+ type: String,
245
+ default: null,
246
+ },
247
+
248
+ /** A note under the module cards. */
249
+ footnote: {
250
+ type: String,
251
+ default: null,
252
+ },
253
+
161
254
  /** Text shown when no card is visible. */
162
255
  emptyLabel: {
163
256
  type: String,
@@ -192,7 +285,7 @@ export default {
192
285
  config() {
193
286
  const fromPage = (this.page && this.page.config) || {}
194
287
  const fromProps = {}
195
- for (const key of ['cards', 'categories', 'description', 'title', 'emptyLabel']) {
288
+ for (const key of ['cards', 'categories', 'description', 'title', 'emptyLabel', 'modules', 'modulesTitle', 'footnote', 'groupStyle']) {
196
289
  if (this[key] !== null && this[key] !== undefined) {
197
290
  fromProps[key] = this[key]
198
291
  }
@@ -261,6 +354,90 @@ export default {
261
354
  .filter((card) => card.url !== '')
262
355
  },
263
356
 
357
+ /**
358
+ * The modules a reader may see, with text translated and links
359
+ * resolved. `enabled` is the declared boolean, or the result of
360
+ * `enabledIf` (a visibleIf condition against `runtime`). A page that
361
+ * resolves to nothing is dropped; the admin-settings link only shows when
362
+ * its own `permission` and `visibleIf` pass.
363
+ *
364
+ * @return {Array<object>} The modules.
365
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-links-page-can-show-modules
366
+ */
367
+ visibleModules() {
368
+ const declared = Array.isArray(this.config.modules) ? this.config.modules : []
369
+ const runtime = this.cnManifest?.runtime ?? null
370
+ return declared
371
+ .filter((module) => module && module.id && module.label)
372
+ .filter((module) => this.passesPermission(module) && this.passesVisibleIf(module, runtime))
373
+ .map((module) => {
374
+ const pages = (Array.isArray(module.pages) ? module.pages : [])
375
+ .filter((page) => page && page.label)
376
+ .map((page, index) => ({ key: String(index), label: this.tr(page.label), highlight: page.highlight === true, ...this.targetOf(page) }))
377
+ .filter((page) => page.url !== '')
378
+ const own = this.targetOf(module)
379
+ const open = own.url !== '' ? own : (pages[0] || null)
380
+ const settingsDeclared = module.settings && typeof module.settings === 'object' ? module.settings : null
381
+ const settingsTarget = settingsDeclared && this.passesPermission(settingsDeclared) && this.passesVisibleIf(settingsDeclared, runtime)
382
+ ? this.targetOf(settingsDeclared)
383
+ : null
384
+ return {
385
+ id: String(module.id),
386
+ key: String(module.id).replace(/[^A-Za-z0-9_-]/g, '-'),
387
+ label: this.tr(module.label),
388
+ description: this.tr(module.description || ''),
389
+ icon: module.icon || '',
390
+ enabled: this.moduleEnabled(module, runtime),
391
+ pages,
392
+ open,
393
+ settings: settingsTarget && settingsTarget.url !== ''
394
+ ? { ...settingsTarget, label: this.tr(settingsDeclared.label || ''), prefix: this.tr(settingsDeclared.prefix || '') }
395
+ : null,
396
+ }
397
+ })
398
+ },
399
+
400
+ /**
401
+ * Whether the groups are drawn as list cards (`config.groupStyle: "list"`).
402
+ *
403
+ * @return {boolean} True for list cards.
404
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-group-can-be-a-list-card
405
+ */
406
+ isListStyle() {
407
+ return this.config.groupStyle === 'list'
408
+ },
409
+
410
+ /**
411
+ * The modules heading, translated.
412
+ *
413
+ * @return {string} The heading.
414
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-links-page-can-show-modules
415
+ */
416
+ resolvedModulesTitle() {
417
+ return this.config.modulesTitle ? this.tr(this.config.modulesTitle) : t('nextcloud-vue', 'Modules')
418
+ },
419
+
420
+ /**
421
+ * "3 of 6 are in the menu".
422
+ *
423
+ * @return {string} The count.
424
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-links-page-can-show-modules
425
+ */
426
+ modulesCount() {
427
+ const on = this.visibleModules.filter((module) => module.enabled).length
428
+ return n('nextcloud-vue', '{on} of %n is in the menu', '{on} of %n are in the menu', this.visibleModules.length, { on })
429
+ },
430
+
431
+ /**
432
+ * The note under the module cards, translated.
433
+ *
434
+ * @return {string} The note, or ''.
435
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-links-page-can-show-modules
436
+ */
437
+ resolvedFootnote() {
438
+ return this.tr(this.config.footnote || '')
439
+ },
440
+
264
441
  /**
265
442
  * The groups to draw: ungrouped cards first under no caption, then
266
443
  * one group per category in declaration order. A group without a
@@ -290,6 +467,40 @@ export default {
290
467
  },
291
468
 
292
469
  methods: {
470
+ /**
471
+ * Whether a module is in the menu: its `enabled` boolean, else its
472
+ * `enabledIf` condition evaluated as a `visibleIf`, else false.
473
+ *
474
+ * @param {object} module The declared module.
475
+ * @param {object|null} runtime `manifest.runtime`, or null.
476
+ * @return {boolean} True when the module is in the menu.
477
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-links-page-can-show-modules
478
+ */
479
+ /**
480
+ * The icon at the end of a list row: an external-link icon for a URL,
481
+ * a chevron for a page in the app. A resolved `trailing` wins.
482
+ *
483
+ * @param {object} card The resolved card.
484
+ * @return {string} `external` or `chevron`.
485
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-group-can-be-a-list-card
486
+ */
487
+ trailingOf(card) {
488
+ if (card.trailing === 'external' || card.trailing === 'chevron') {
489
+ return card.trailing
490
+ }
491
+ return card.external ? 'external' : 'chevron'
492
+ },
493
+
494
+ moduleEnabled(module, runtime) {
495
+ if (typeof module.enabled === 'boolean') {
496
+ return module.enabled
497
+ }
498
+ if (module.enabledIf && typeof module.enabledIf === 'object') {
499
+ return this.passesVisibleIf({ visibleIf: module.enabledIf }, runtime)
500
+ }
501
+ return false
502
+ },
503
+
293
504
  /**
294
505
  * Translate a manifest string through the app's translate function.
295
506
  *
@@ -514,4 +725,126 @@ export default {
514
725
  font-size: 0.9em;
515
726
  line-height: 1.4;
516
727
  }
728
+
729
+ .cn-link-cards-page__group--list {
730
+ padding: 20px 22px;
731
+ border: 1px solid var(--color-border);
732
+ border-radius: var(--border-radius-container-large, 12px);
733
+ background: var(--color-main-background);
734
+ gap: 6px;
735
+ }
736
+
737
+ .cn-link-cards-page__group--list .cn-link-cards-page__caption {
738
+ margin: 0 0 8px;
739
+ color: var(--color-main-text);
740
+ font-size: 17px;
741
+ font-weight: 700;
742
+ letter-spacing: normal;
743
+ text-transform: none;
744
+ }
745
+
746
+ .cn-link-cards-page__list {
747
+ margin: 0;
748
+ padding: 0;
749
+ list-style: none;
750
+ }
751
+
752
+ .cn-link-cards-page__row {
753
+ display: flex;
754
+ align-items: center;
755
+ gap: 12px;
756
+ padding: 10px 0;
757
+ border-top: 1px solid var(--color-background-dark);
758
+ color: var(--color-main-text);
759
+ }
760
+
761
+ .cn-link-cards-page__row,
762
+ .cn-link-cards-page__row:hover,
763
+ .cn-link-cards-page__row:focus,
764
+ .cn-link-cards-page__row:active {
765
+ text-decoration: none !important;
766
+ }
767
+
768
+ .cn-link-cards-page__row:focus-visible {
769
+ outline: 2px solid var(--color-primary-element);
770
+ outline-offset: 2px;
771
+ }
772
+
773
+ .cn-link-cards-page__row-icon {
774
+ display: flex;
775
+ flex: none;
776
+ align-items: center;
777
+ justify-content: center;
778
+ width: 34px;
779
+ height: 34px;
780
+ border-radius: var(--border-radius-element, 8px);
781
+ background: var(--color-background-dark);
782
+ }
783
+
784
+ .cn-link-cards-page__row-body {
785
+ display: flex;
786
+ flex: 1 1 auto;
787
+ flex-direction: column;
788
+ gap: 2px;
789
+ min-width: 0;
790
+ }
791
+
792
+ .cn-link-cards-page__row-label {
793
+ font-size: 15px;
794
+ font-weight: 600;
795
+ }
796
+
797
+ .cn-link-cards-page__row-description {
798
+ font-size: 13px;
799
+ color: var(--color-text-maxcontrast);
800
+ }
801
+
802
+ .cn-link-cards-page__row-trailing {
803
+ flex: none;
804
+ color: var(--color-text-maxcontrast);
805
+ }
806
+
807
+ .cn-link-cards-page__modules {
808
+ display: flex;
809
+ flex-direction: column;
810
+ gap: 14px;
811
+ }
812
+
813
+ .cn-link-cards-page__modules-head {
814
+ display: flex;
815
+ flex-wrap: wrap;
816
+ justify-content: space-between;
817
+ align-items: baseline;
818
+ gap: 8px;
819
+ }
820
+
821
+ .cn-link-cards-page__modules-title {
822
+ margin: 0;
823
+ font-size: 20px;
824
+ font-weight: 700;
825
+ }
826
+
827
+ .cn-link-cards-page__modules-count {
828
+ font-size: 14px;
829
+ color: var(--color-text-maxcontrast);
830
+ }
831
+
832
+ .cn-link-cards-page__modules-grid {
833
+ display: grid;
834
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
835
+ gap: 16px;
836
+ }
837
+
838
+ .cn-link-cards-page__modules-note {
839
+ display: flex;
840
+ align-items: flex-start;
841
+ gap: 8px;
842
+ margin: 0;
843
+ font-size: 14px;
844
+ color: var(--color-text-maxcontrast);
845
+ }
846
+
847
+ .cn-link-cards-page__modules-note-icon {
848
+ flex: none;
849
+ }
517
850
  </style>
@@ -0,0 +1,258 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <article
7
+ class="cn-module-card"
8
+ :aria-labelledby="headingId"
9
+ data-testid="cn-module-card"
10
+ :data-module-id="module.id">
11
+ <div class="cn-module-card__head">
12
+ <span
13
+ v-if="module.icon"
14
+ class="cn-module-card__icon"
15
+ :class="{ 'cn-module-card__icon--off': !module.enabled }"
16
+ aria-hidden="true">
17
+ <CnIcon :name="module.icon" :size="22" />
18
+ </span>
19
+ <h3 :id="headingId" class="cn-module-card__title">
20
+ {{ module.label }}
21
+ </h3>
22
+ <CnStatusBadge
23
+ class="cn-module-card__state"
24
+ :label="module.enabled ? t('nextcloud-vue', 'In the menu') : t('nextcloud-vue', 'Off')"
25
+ :variant="module.enabled ? 'success' : 'default'"
26
+ data-testid="cn-module-card-state" />
27
+ </div>
28
+ <p v-if="module.description" class="cn-module-card__description">
29
+ {{ module.description }}
30
+ </p>
31
+ <template v-if="module.pages.length > 0">
32
+ <span class="cn-module-card__pages-line" data-testid="cn-module-card-pages-line">{{ pagesLine }}</span>
33
+ <ul class="cn-module-card__pages" :aria-label="t('nextcloud-vue', 'Pages')">
34
+ <li v-for="page in module.pages" :key="page.key">
35
+ <a
36
+ class="cn-module-card__page"
37
+ :href="page.url"
38
+ @click="follow(page, $event)">{{ page.label }}</a>
39
+ </li>
40
+ </ul>
41
+ </template>
42
+ <div v-if="module.open || (!module.enabled && module.settings)" class="cn-module-card__footer">
43
+ <a
44
+ v-if="module.open"
45
+ class="cn-module-card__open"
46
+ :href="module.open.url"
47
+ :aria-label="t('nextcloud-vue', 'Open {module}', { module: module.label })"
48
+ data-testid="cn-module-card-open"
49
+ @click="follow(module.open, $event)">
50
+ {{ t('nextcloud-vue', 'Open') }}
51
+ <ChevronRight :size="18" />
52
+ </a>
53
+ <span v-if="!module.enabled && module.settings" class="cn-module-card__settings" data-testid="cn-module-card-settings">
54
+ {{ module.settings.prefix || t('nextcloud-vue', 'Put it in the menu:') }}
55
+ <a
56
+ :href="module.settings.url"
57
+ :target="module.settings.external ? '_blank' : null"
58
+ :rel="module.settings.external ? 'noopener noreferrer' : null"
59
+ @click="follow(module.settings, $event)">{{ module.settings.label || t('nextcloud-vue', 'Admin settings') }}</a>
60
+ </span>
61
+ </div>
62
+ </article>
63
+ </template>
64
+
65
+ <script>
66
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
67
+ import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
68
+ import CnStatusBadge from '../CnStatusBadge/CnStatusBadge.vue'
69
+ import { followLinkClick } from '../../utils/linkNavigation.js'
70
+ import { CnIcon } from '../CnIcon/index.js'
71
+
72
+ /** A module with more pages than this names its highlights instead of every page. */
73
+ const LISTED_PAGES = 4
74
+
75
+ /**
76
+ * CnModuleCard - one module on a `type: "links"` page (board PqModules): an
77
+ * icon, the title and a state badge (in the menu, or off), a description,
78
+ * the module's pages as a line and as links, an Open button, and for a
79
+ * module that is off a link to where an admin puts it in the menu.
80
+ *
81
+ * Rendered by CnLinkCardsPage for each entry of `config.modules`; the page
82
+ * resolves the links and the text before handing the module over.
83
+ *
84
+ * @spec openspec/changes/screens-links-module-cards/specs/link-cards-page/spec.md#requirement-a-links-page-can-show-modules
85
+ */
86
+ export default {
87
+ name: 'CnModuleCard',
88
+
89
+ components: { ChevronRight, CnIcon, CnStatusBadge },
90
+
91
+ props: {
92
+ /**
93
+ * The resolved module: `{ id, label, description, icon, enabled,
94
+ * pages: [{ key, label, url, location, highlight }], open: { url,
95
+ * location }|null, settings: { url, location, external, label, prefix }|null }`.
96
+ */
97
+ module: {
98
+ type: Object,
99
+ required: true,
100
+ },
101
+
102
+ /** Id of the card's heading, unique on the page. */
103
+ headingId: {
104
+ type: String,
105
+ required: true,
106
+ },
107
+ },
108
+
109
+ computed: {
110
+ /**
111
+ * "4 pages: Overview, Leads, Prospects, Pipeline", or for a larger
112
+ * module "15 pages, including Campaigns, Blasts and Mailing lists"
113
+ * from the pages marked `highlight` (else the first three).
114
+ *
115
+ * @return {string} The line.
116
+ */
117
+ pagesLine() {
118
+ const pages = this.module.pages
119
+ if (pages.length <= LISTED_PAGES) {
120
+ return n('nextcloud-vue', '%n page: {pages}', '%n pages: {pages}', pages.length, { pages: pages.map((page) => page.label).join(', ') })
121
+ }
122
+ const marked = pages.filter((page) => page.highlight)
123
+ const named = (marked.length > 0 ? marked : pages).slice(0, 3).map((page) => page.label)
124
+ const last = named.pop()
125
+ const list = named.length > 0 ? t('nextcloud-vue', '{list} and {last}', { list: named.join(', '), last }) : last
126
+ return n('nextcloud-vue', '%n page, including {pages}', '%n pages, including {pages}', pages.length, { pages: list })
127
+ },
128
+ },
129
+
130
+ methods: {
131
+ t,
132
+
133
+ /**
134
+ * Route a plain click on an in-app link, so the page does not reload.
135
+ *
136
+ * @param {{location: (object|null)}} target The resolved link.
137
+ * @param {MouseEvent} event The click.
138
+ */
139
+ follow(target, event) {
140
+ if (target && target.location) {
141
+ followLinkClick(event, target.location, this.$router)
142
+ }
143
+ },
144
+ },
145
+ }
146
+ </script>
147
+
148
+ <style scoped>
149
+ .cn-module-card {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 12px;
153
+ padding: 20px;
154
+ border: 1px solid var(--color-border);
155
+ border-radius: var(--border-radius-container-large, 12px);
156
+ background: var(--color-main-background);
157
+ }
158
+
159
+ .cn-module-card__head {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 12px;
163
+ }
164
+
165
+ .cn-module-card__icon {
166
+ display: flex;
167
+ flex: none;
168
+ align-items: center;
169
+ justify-content: center;
170
+ width: 42px;
171
+ height: 42px;
172
+ border-radius: var(--border-radius-large, 10px);
173
+ background: var(--color-primary-element-light);
174
+ color: var(--color-primary-element-light-text, var(--color-primary-element));
175
+ }
176
+
177
+ .cn-module-card__title {
178
+ flex: 1 1 auto;
179
+ margin: 0;
180
+ font-size: 17px;
181
+ font-weight: 700;
182
+ }
183
+
184
+ .cn-module-card__state {
185
+ flex: none;
186
+ }
187
+
188
+ .cn-module-card__description {
189
+ margin: 0;
190
+ font-size: 15px;
191
+ line-height: 1.5;
192
+ }
193
+
194
+ .cn-module-card__pages-line {
195
+ font-size: 13px;
196
+ color: var(--color-text-maxcontrast);
197
+ }
198
+
199
+ .cn-module-card__pages {
200
+ display: flex;
201
+ flex-wrap: wrap;
202
+ gap: 6px 12px;
203
+ margin: 0;
204
+ padding: 0;
205
+ list-style: none;
206
+ font-size: 14px;
207
+ }
208
+
209
+ .cn-module-card__page {
210
+ font-weight: 600;
211
+ color: var(--color-primary-element);
212
+ }
213
+
214
+ .cn-module-card__footer {
215
+ display: flex;
216
+ flex-wrap: wrap;
217
+ align-items: center;
218
+ gap: 8px 14px;
219
+ margin-top: auto;
220
+ padding-top: 4px;
221
+ }
222
+
223
+ .cn-module-card__open {
224
+ display: inline-flex;
225
+ align-items: center;
226
+ gap: 6px;
227
+ box-sizing: border-box;
228
+ height: 36px;
229
+ padding: 0 14px;
230
+ font-size: 14px;
231
+ font-weight: 600;
232
+ text-decoration: none;
233
+ }
234
+
235
+ .cn-module-card__open {
236
+ border: 1px solid var(--color-border-dark);
237
+ border-radius: var(--border-radius-element, 8px);
238
+ background: var(--color-main-background);
239
+ color: var(--color-main-text);
240
+ }
241
+
242
+ .cn-module-card__icon--off {
243
+ background: var(--color-background-dark);
244
+ color: var(--color-main-text);
245
+ }
246
+
247
+ .cn-module-card__settings {
248
+ font-size: 13px;
249
+ color: var(--color-text-maxcontrast);
250
+ }
251
+
252
+ .cn-module-card__open:focus-visible,
253
+ .cn-module-card__settings a:focus-visible,
254
+ .cn-module-card__page:focus-visible {
255
+ outline: 2px solid var(--color-primary-element);
256
+ outline-offset: 2px;
257
+ }
258
+ </style>