@conduction/nextcloud-vue 2.91.0 → 2.92.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 (57) hide show
  1. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +10 -5
  2. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  3. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  4. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  5. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +18 -10
  6. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  7. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +16 -8
  8. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  9. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +1 -1
  10. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +156 -3
  11. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  12. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +168 -52
  13. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  14. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js +9 -0
  15. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js.map +1 -0
  16. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js +83 -0
  17. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js.map +1 -0
  18. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js +119 -0
  19. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js.map +1 -0
  20. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  21. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -2
  22. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  23. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  24. package/dist/esm/components/CnTextWidget/CnTextWidget.vue.js +1 -1
  25. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js +63 -4
  26. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js.map +1 -1
  27. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js +19 -2
  28. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js.map +1 -1
  29. package/dist/esm/l10n/en.json.js +22 -1
  30. package/dist/esm/l10n/en.json.js.map +1 -1
  31. package/dist/esm/l10n/nl.json.js +21 -1
  32. package/dist/esm/l10n/nl.json.js.map +1 -1
  33. package/dist/esm/library-style.js +1 -1
  34. package/dist/esm/nextcloud-vue.css +317 -103
  35. package/dist/esm/utils/boardDate.js +35 -1
  36. package/dist/esm/utils/boardDate.js.map +1 -1
  37. package/dist/esm/utils/fillTemplate.js +12 -3
  38. package/dist/esm/utils/fillTemplate.js.map +1 -1
  39. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  40. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  41. package/dist/nextcloud-vue.cjs.js +11451 -10784
  42. package/dist/nextcloud-vue.cjs.js.map +2 -2
  43. package/dist/nextcloud-vue.css +317 -103
  44. package/l10n/en.json +22 -1
  45. package/l10n/nl.json +21 -1
  46. package/package.json +1 -1
  47. package/src/components/CnDataTable/CnDataTable.vue +10 -5
  48. package/src/components/CnFormDialog/CnFormDialog.md +1 -0
  49. package/src/components/CnFormDialog/CnFormDialog.vue +13 -0
  50. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +338 -5
  51. package/src/components/CnModuleCard/CnModuleCard.vue +258 -0
  52. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -2
  53. package/src/components/CnTextWidget/CnTextWidget.vue +89 -4
  54. package/src/schemas/app-manifest-v2.schema.json +59 -2
  55. package/src/utils/boardDate.js +34 -0
  56. package/src/utils/fillTemplate.js +12 -3
  57. package/src/utils/validateManifestV2.compiled.js +3 -3
package/l10n/en.json CHANGED
@@ -499,6 +499,7 @@
499
499
  "NC Activity is currently unavailable.": "NC Activity is currently unavailable.",
500
500
  "Could not load activity.": "Could not load activity.",
501
501
  "Today": "Today",
502
+ "Today, {date}": "Today, {date}",
502
503
  "Last 8 hours": "Last 8 hours",
503
504
  "Last 24 hours": "Last 24 hours",
504
505
  "Last 7 days": "Last 7 days",
@@ -1473,7 +1474,27 @@
1473
1474
  "Link related object": "Link related object",
1474
1475
  "{type} parts": "{type} parts",
1475
1476
  "Cannot save: this record has no id, so the edit would create a duplicate instead of updating it.": "Cannot save: this record has no id, so the edit would create a duplicate instead of updating it.",
1476
- "Save failed": "Save failed"
1477
+ "Save failed": "Save failed",
1478
+ "Modules": "Modules",
1479
+ "In the menu": "In the menu",
1480
+ "Off": "Off",
1481
+ "Pages": "Pages",
1482
+ "Open {module}": "Open {module}",
1483
+ "{list} and {last}": "{list} and {last}",
1484
+ "%n page: {pages}": [
1485
+ "%n page: {pages}",
1486
+ "%n pages: {pages}"
1487
+ ],
1488
+ "%n page, including {pages}": [
1489
+ "%n page, including {pages}",
1490
+ "%n pages, including {pages}"
1491
+ ],
1492
+ "{on} of %n is in the menu": [
1493
+ "{on} of %n is in the menu",
1494
+ "{on} of %n are in the menu"
1495
+ ],
1496
+ "Put it in the menu:": "Put it in the menu:",
1497
+ "Admin settings": "Admin settings"
1477
1498
  },
1478
1499
  "plurals": {
1479
1500
  "{count} selected": [
package/l10n/nl.json CHANGED
@@ -521,6 +521,7 @@
521
521
  "NC Activity is currently unavailable.": "NC Activiteit is momenteel niet beschikbaar.",
522
522
  "Could not load activity.": "Kon activiteit niet laden.",
523
523
  "Today": "Vandaag",
524
+ "Today, {date}": "Vandaag, {date}",
524
525
  "Last 8 hours": "Laatste 8 uur",
525
526
  "Last 24 hours": "Laatste 24 uur",
526
527
  "Last 7 days": "Laatste 7 dagen",
@@ -1660,7 +1661,26 @@
1660
1661
  "Link related object": "Gerelateerd object koppelen",
1661
1662
  "{type} parts": "Onderdelen van {type}",
1662
1663
  "Cannot save: this record has no id, so the edit would create a duplicate instead of updating it.": "Opslaan kan niet: dit record heeft geen id, dus de wijziging zou een kopie maken in plaats van het record bij te werken.",
1663
- "Save failed": "Opslaan is mislukt"
1664
+ "Save failed": "Opslaan is mislukt",
1665
+ "Modules": "Modules",
1666
+ "In the menu": "In het menu",
1667
+ "Off": "Uit",
1668
+ "Pages": "Pagina's",
1669
+ "Open {module}": "{module} openen",
1670
+ "{list} and {last}": "{list} en {last}",
1671
+ "%n page: {pages}": [
1672
+ "%n pagina: {pages}",
1673
+ "%n pagina's: {pages}"
1674
+ ],
1675
+ "%n page, including {pages}": [
1676
+ "%n pagina, waaronder {pages}",
1677
+ "%n pagina's, waaronder {pages}"
1678
+ ],
1679
+ "{on} of %n is in the menu": [
1680
+ "{on} van %n staat in het menu",
1681
+ "{on} van %n staan in het menu"
1682
+ ],
1683
+ "Put it in the menu:": "In het menu zetten:"
1664
1684
  },
1665
1685
  "plurals": {
1666
1686
  "{count} selected": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.91.0",
3
+ "version": "2.92.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -404,6 +404,7 @@ import CnColumnFilterPopover from './CnColumnFilterPopover.vue'
404
404
  import { useClickDragGuard } from '../../composables/useClickDragGuard.js'
405
405
  import { normalizeLook } from '../../composables/useLook.js'
406
406
  import { clearedColumnFilterParams, columnFilterDef, columnFilterParams, columnFilterState, isColumnFilterActive, isColumnSortable } from '../../utils/columnFilters.js'
407
+ import { fillTemplate } from '../../utils/fillTemplate.js'
407
408
  import { lensUnavailableText, readLensReports } from '../../utils/lensAvailability.js'
408
409
  import { followLinkClick, openRowTarget, resolveHref } from '../../utils/linkNavigation.js'
409
410
  import { nextSortState } from '../../utils/multiColumnSort.js'
@@ -561,8 +562,8 @@ export default {
561
562
  *
562
563
  * `secondary` draws a second, muted line under the cell's value: a field
563
564
  * key (`"number"`), a template with `{field}` placeholders
564
- * (`"{number} · {requester}"`, dotted paths allowed), or a function of
565
- * the row. Empty fields leave their placeholder blank; a line that
565
+ * (`"{number} · {requester}"`, dotted paths allowed; `{startAt|weekday}`
566
+ * formats through `fillTemplate`), or a function of the row. Empty fields leave their placeholder blank; a line that
566
567
  * resolves to nothing is not drawn.
567
568
  *
568
569
  * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, secondary: (string|((row: object) => string))}|string>}
@@ -1769,8 +1770,10 @@ export default {
1769
1770
  /**
1770
1771
  * The secondary line of a cell, from `col.secondary`: a function is
1771
1772
  * called with the row; a string with `{field}` placeholders is a
1772
- * template filled from the row (dotted paths allowed); any other
1773
- * string is a field key. Returns '' when there is nothing to draw.
1773
+ * template filled from the row (dotted paths allowed), where a
1774
+ * `{field|format}` token is formatted by `fillTemplate` ("{startAt|weekday}"
1775
+ * reads "wo 7 okt"); any other string is a field key. Returns '' when
1776
+ * there is nothing to draw.
1774
1777
  *
1775
1778
  * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-table-column-carries-a-secondary-line
1776
1779
  * @param {object} row The row.
@@ -1787,7 +1790,9 @@ export default {
1787
1790
  return ''
1788
1791
  }
1789
1792
  if (spec.includes('{')) {
1790
- const filled = fillSecondaryTemplate(spec, (key) => this.secondaryFieldText(row, key))
1793
+ const filled = fillSecondaryTemplate(spec, (key) => (key.includes('|')
1794
+ ? fillTemplate(`{${key}}`, row)
1795
+ : this.secondaryFieldText(row, key)))
1791
1796
  // A template whose every field is empty leaves only separators:
1792
1797
  // nothing worth a line.
1793
1798
  return /[\p{L}\p{N}]/u.test(filled) ? filled.trim() : ''
@@ -169,6 +169,7 @@ All user-visible strings have props so they can be pre-translated by the consume
169
169
  | `cancelLabel` | `'Cancel'` | Label for the dismiss button before the action is confirmed. |
170
170
  | `closeLabel` | `'Close'` | Label for the dismiss button after the result is shown. |
171
171
  | `confirmLabel` | `''` | Confirm button label. Defaults to `'Create'` or `'Save'` depending on mode. |
172
+ | `confirmIcon` | `''` | MDI icon name on the Confirm button (`SwapHorizontal`, `Archive`, `Send`, `Gavel`). Defaults to Plus when creating, else a save icon. |
172
173
  | `feedback` | `true` | Toast the write: "Saved {title}" on success, the server's message on failure. `false` suppresses the toasts only. |
173
174
  | `confirmDisabled` | `false` | Keep the Confirm button disabled regardless of the form state. |
174
175
 
@@ -758,6 +758,11 @@
758
758
  @click="executeConfirm">
759
759
  <template #icon>
760
760
  <NcLoadingIcon v-if="loading" :size="20" />
761
+ <CnIcon
762
+ v-else-if="confirmIcon"
763
+ :name="confirmIcon"
764
+ :size="20"
765
+ data-testid="cn-form-dialog-confirm-icon" />
761
766
  <Plus v-else-if="isCreateMode" :size="20" />
762
767
  <ContentSaveOutline v-else :size="20" />
763
768
  </template>
@@ -781,6 +786,7 @@ import CnDurationField from '../CnDurationField/CnDurationField.vue'
781
786
  import CnFieldHelper from '../CnFieldHelper/CnFieldHelper.vue'
782
787
  import CnFileField from '../CnFileField/CnFileField.vue'
783
788
  import CnFormField from '../CnFormField/CnFormField.vue'
789
+ import CnIcon from '../CnIcon/CnIcon.vue'
784
790
  import CnIconBrowser from '../CnIconBrowser/CnIconBrowser.vue'
785
791
  import CnJsonViewer from '../CnJsonViewer/CnJsonViewer.vue'
786
792
  import CnPropertySourceField from '../CnPropertySourceField/CnPropertySourceField.vue'
@@ -1023,6 +1029,7 @@ export default {
1023
1029
  CnSubObjectsField,
1024
1030
  CnChildRecordsField,
1025
1031
  CnDialog,
1032
+ CnIcon,
1026
1033
  CnFormField,
1027
1034
  NcButton,
1028
1035
  NcNoteCard,
@@ -1303,6 +1310,12 @@ export default {
1303
1310
  default: '',
1304
1311
  },
1305
1312
 
1313
+ /** MDI icon name on the Confirm button (`SwapHorizontal`, `Archive`, `Send`, `Gavel`). Defaults to Plus when creating, else a save icon. */
1314
+ confirmIcon: {
1315
+ type: String,
1316
+ default: '',
1317
+ },
1318
+
1306
1319
  /** Keep the Confirm button disabled regardless of the form state, for a host that knows the form cannot be completed. */
1307
1320
  confirmDisabled: {
1308
1321
  type: Boolean,
@@ -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>