@conduction/nextcloud-vue 2.60.0 → 2.61.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 (56) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +41 -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 +9 -2
  5. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  6. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  7. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +10 -2
  8. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  9. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  11. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +6 -5
  12. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  13. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +3 -1
  14. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  15. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +10 -0
  17. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js.map +1 -0
  18. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +316 -0
  19. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -0
  20. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +105 -0
  21. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -0
  22. package/dist/esm/components/CnPageRenderer/pageTypes.js +1 -0
  23. package/dist/esm/components/CnPageRenderer/pageTypes.js.map +1 -1
  24. package/dist/esm/index.js +6 -0
  25. package/dist/esm/index.js.map +1 -1
  26. package/dist/esm/l10n/en.json.js +1 -0
  27. package/dist/esm/l10n/en.json.js.map +1 -1
  28. package/dist/esm/l10n/nl.json.js +1 -0
  29. package/dist/esm/l10n/nl.json.js.map +1 -1
  30. package/dist/esm/nextcloud-vue.css +92 -2
  31. package/dist/esm/utils/headers.js +28 -2
  32. package/dist/esm/utils/headers.js.map +1 -1
  33. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  34. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  35. package/dist/nextcloud-vue.cjs.js +8741 -8229
  36. package/dist/nextcloud-vue.cjs.js.map +2 -2
  37. package/dist/nextcloud-vue.css +92 -2
  38. package/l10n/en.json +1 -0
  39. package/l10n/nl.json +1 -0
  40. package/package.json +1 -1
  41. package/scripts/.jsdoc-baselines.json +1 -0
  42. package/src/components/CnAppNav/CnAppNav.vue +41 -1
  43. package/src/components/CnBannerWidget/CnBannerWidget.vue +9 -2
  44. package/src/components/CnDashboardPage/CnDashboardPage.vue +10 -2
  45. package/src/components/CnDetailPage/CnDetailPage.vue +4 -1
  46. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +3 -1
  47. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +484 -0
  48. package/src/components/CnLinkCardsPage/index.js +4 -0
  49. package/src/components/CnPageRenderer/pageTypes.js +1 -0
  50. package/src/components/index.js +1 -0
  51. package/src/css/detail-page.css +10 -2
  52. package/src/index.js +7 -0
  53. package/src/schemas/app-manifest-v2.schema.json +44 -2
  54. package/src/types/manifest.d.ts +1 -1
  55. package/src/utils/headers.js +28 -2
  56. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -0,0 +1,484 @@
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
+ .cn-link-cards-page__title {
399
+ margin: 0 0 var(--default-grid-baseline);
400
+ }
401
+
402
+ .cn-link-cards-page__description,
403
+ .cn-link-cards-page__empty {
404
+ color: var(--color-text-maxcontrast);
405
+ margin: 0;
406
+ max-width: 70ch;
407
+ }
408
+
409
+ .cn-link-cards-page__group {
410
+ display: flex;
411
+ flex-direction: column;
412
+ gap: calc(3 * var(--default-grid-baseline));
413
+ }
414
+
415
+ .cn-link-cards-page__caption {
416
+ color: var(--color-text-maxcontrast);
417
+ font-size: 0.85em;
418
+ font-weight: 600;
419
+ letter-spacing: 0.06em;
420
+ margin: 0;
421
+ text-transform: uppercase;
422
+ }
423
+
424
+ .cn-link-cards-page__grid {
425
+ display: grid;
426
+ gap: calc(3 * var(--default-grid-baseline));
427
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
428
+ list-style: none;
429
+ margin: 0;
430
+ padding: 0;
431
+ }
432
+
433
+ .cn-link-cards-page__cell {
434
+ display: flex;
435
+ }
436
+
437
+ .cn-link-cards-page__card {
438
+ align-items: flex-start;
439
+ background: var(--color-main-background);
440
+ border: 1px solid var(--color-border-dark);
441
+ border-radius: var(--border-radius-large);
442
+ color: var(--color-main-text);
443
+ display: flex;
444
+ flex: 1;
445
+ gap: calc(3 * var(--default-grid-baseline));
446
+ min-width: 0;
447
+ padding: calc(4 * var(--default-grid-baseline));
448
+ text-decoration: none;
449
+ }
450
+
451
+ .cn-link-cards-page__card:hover {
452
+ background: var(--color-background-hover);
453
+ border-color: var(--color-primary-element);
454
+ }
455
+
456
+ .cn-link-cards-page__card:focus-visible {
457
+ border-color: var(--color-primary-element);
458
+ outline: 2px solid var(--color-primary-element);
459
+ outline-offset: 2px;
460
+ }
461
+
462
+ .cn-link-cards-page__icon {
463
+ color: var(--color-primary-element);
464
+ flex: none;
465
+ }
466
+
467
+ .cn-link-cards-page__body {
468
+ display: flex;
469
+ flex-direction: column;
470
+ gap: var(--default-grid-baseline);
471
+ min-width: 0;
472
+ }
473
+
474
+ .cn-link-cards-page__label {
475
+ font-weight: 600;
476
+ overflow-wrap: anywhere;
477
+ }
478
+
479
+ .cn-link-cards-page__card-description {
480
+ color: var(--color-text-maxcontrast);
481
+ font-size: 0.9em;
482
+ line-height: 1.4;
483
+ }
484
+ </style>
@@ -0,0 +1,4 @@
1
+ import CnLinkCardsPage from './CnLinkCardsPage.vue'
2
+
3
+ export default CnLinkCardsPage
4
+ export { CnLinkCardsPage }
@@ -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)),
@@ -221,6 +221,7 @@ export { CnFlowEditorPage, CnFlowsPage } from './CnFlowsPage/index.js'
221
221
  export { default as CnWidgetRefItem } from './CnWidgetRefItem/index.js'
222
222
  export { CnReportsPage } from './CnReportsPage/index.js'
223
223
  export { CnStorePage } from './CnStorePage/index.js'
224
+ export { CnLinkCardsPage } from './CnLinkCardsPage/index.js'
224
225
  export { CnPageRenderer, defaultPageTypes } from './CnPageRenderer/index.js'
225
226
  export { CnAppNav } from './CnAppNav/index.js'
226
227
  export { CnAppLoading } from './CnAppLoading/index.js'
@@ -73,9 +73,17 @@
73
73
  font-weight: 700;
74
74
  line-height: 1.3;
75
75
  color: var(--color-main-text);
76
- white-space: nowrap;
76
+ /* Two lines, then an ellipsis. One line cut a long case title short on a
77
+ page with room to spare, and the title is the one thing that says which
78
+ record this is. The element carries the full text as its `title`, so a
79
+ title cut after two lines is still reachable. `overflow-wrap` breaks a
80
+ word longer than the line, which nowrap used to hide. */
81
+ display: -webkit-box;
82
+ -webkit-box-orient: vertical;
83
+ -webkit-line-clamp: 2;
84
+ line-clamp: 2;
77
85
  overflow: hidden;
78
- text-overflow: ellipsis;
86
+ overflow-wrap: anywhere;
79
87
  }
80
88
 
81
89
  .cn-detail-page__subtitle {
package/src/index.js CHANGED
@@ -311,6 +311,13 @@ export { CnOpenBuildEditButton } from './components/index.js'
311
311
  // control, and the brand stripe.
312
312
  export { CnBrandStripe, CnSegmentedControl, CnStackedBarWidget, CnStackedBarWidgetForm, CnWeekStripWidget, CnWeekStripWidgetForm } from './components/index.js'
313
313
 
314
+ // Page components the page renderer mounts by `pages[].type` that were missing
315
+ // from this entry. A component reachable from a manifest but not importable
316
+ // here resolves to `undefined` in a host that mounts it by hand, and renders
317
+ // nothing without an error. tests/packaging/page-types-are-exported.spec.js
318
+ // keeps the two lists together.
319
+ export { CnFormPage, CnLinkCardsPage, CnMapPage, CnReportsPage, CnStorePage, CnWikiPage } from './components/index.js'
320
+
314
321
  // AI Chat Companion component family
315
322
  export {
316
323
  CnAiAgentPicker,
@@ -1328,6 +1328,29 @@
1328
1328
  }
1329
1329
  }
1330
1330
  },
1331
+ "linkCard": {
1332
+ "type": "object",
1333
+ "required": ["id", "label"],
1334
+ "additionalProperties": false,
1335
+ "description": "One card on a type='links' page: a link to another page of the app, or to a URL. Same vocabulary as menuItem and navCardEntry.",
1336
+ "properties": {
1337
+ "id": { "type": "string", "minLength": 1, "description": "Unique within the page's cards." },
1338
+ "label": { "type": "string", "minLength": 1, "description": "i18n key / text of the card's title." },
1339
+ "description": { "type": "string", "description": "i18n key / one line under the title." },
1340
+ "icon": { "type": "string", "description": "MDI icon name (e.g. 'CashMultiple')." },
1341
+ "category": { "type": "string", "description": "Key in config.categories naming the group this card sits in. A card without one, or with an unknown one, renders first under no caption." },
1342
+ "route": { "type": "string", "minLength": 1, "description": "Route name (a pages[].id), or a router path when it starts with '/'. Resolved through the router, so it works with and without /index.php. Mutually exclusive with href." },
1343
+ "params": { "type": "object", "description": "Route params passed along with `route`.", "additionalProperties": { "type": ["string", "number"] } },
1344
+ "query": { "type": "object", "description": "Query passed along with `route`.", "additionalProperties": { "type": ["string", "number", "boolean"] } },
1345
+ "href": { "type": "string", "minLength": 1, "description": "External URL, opened in a new tab. Mutually exclusive with route." },
1346
+ "permission": { "type": "string", "description": "Permission the user must hold for the card to render. Same rule as menuItem.permission." },
1347
+ "visibleIf": { "$ref": "#/$defs/visibleIfCondition", "description": "Display condition, evaluated the way a menu entry's is." }
1348
+ },
1349
+ "allOf": [
1350
+ { "not": { "required": ["route", "href"] } },
1351
+ { "anyOf": [{ "required": ["route"] }, { "required": ["href"] }] }
1352
+ ]
1353
+ },
1331
1354
  "navCardEntry": {
1332
1355
  "type": "object",
1333
1356
  "required": ["id", "label"],
@@ -2858,8 +2881,8 @@
2858
2881
  "type": {
2859
2882
  "type": "string",
2860
2883
  "enum": ["index", "detail", "dashboard", "logs", "settings", "chat", "files", "form", "map", "roadmap", "reports",
2861
- "store", "search", "wiki", "flow", "flows", "flow-detail", "custom"],
2862
- "description": "Page type. Closed enum of 17 supported types. 'flow' opens ONE flow in the editor; the flow LIST is an ordinary 'index' with config.entitySource='flows'. 'flows' and 'flow-detail' are DEPRECATED aliases kept for migration — 'flows' predates named index sources and 'flow-detail' is the old name for 'flow'. 'roadmap' mounts CnFeaturesAndRoadmapPage (features + GitHub-issue-backed roadmap). 'search' mounts CnSearchPage (cross-schema query + facet sidebar + results list; consumers wire the actual search via @search). 'wiki' mounts CnWikiPage (manifest-declared markdown article + optional sidebar tree; MUST declare config.register + config.schema). 'flow' and the deprecated 'flow-detail' both mount CnFlowEditorPage (the shared node/edge canvas) and take config.app to scope to the owning app. A flow lives in OpenRegister's native flow store rather than a register/schema pair, so a single flow cannot be a 'detail' page — but the LIST is a plain 'index' once a named entitySource supplies it, which is why 'flows' is deprecated (ADR-110 Decision 4). 'custom' requires a _note field explaining why a standard type was not feasible."
2884
+ "links", "store", "search", "wiki", "flow", "flows", "flow-detail", "custom"],
2885
+ "description": "Page type. Closed enum of 18 supported types. 'links' mounts CnLinkCardsPage: a page of link cards grouped under captions, declared with config.categories + config.cards ($defs/linkCard). Use it for the pages a short menu leaves out; unlike 'reports' an app may have several. 'flow' opens ONE flow in the editor; the flow LIST is an ordinary 'index' with config.entitySource='flows'. 'flows' and 'flow-detail' are DEPRECATED aliases kept for migration — 'flows' predates named index sources and 'flow-detail' is the old name for 'flow'. 'roadmap' mounts CnFeaturesAndRoadmapPage (features + GitHub-issue-backed roadmap). 'search' mounts CnSearchPage (cross-schema query + facet sidebar + results list; consumers wire the actual search via @search). 'wiki' mounts CnWikiPage (manifest-declared markdown article + optional sidebar tree; MUST declare config.register + config.schema). 'flow' and the deprecated 'flow-detail' both mount CnFlowEditorPage (the shared node/edge canvas) and take config.app to scope to the owning app. A flow lives in OpenRegister's native flow store rather than a register/schema pair, so a single flow cannot be a 'detail' page — but the LIST is a plain 'index' once a named entitySource supplies it, which is why 'flows' is deprecated (ADR-110 Decision 4). 'custom' requires a _note field explaining why a standard type was not feasible."
2863
2886
  },
2864
2887
  "title": {
2865
2888
  "type": "string",
@@ -3652,6 +3675,25 @@
3652
3675
  "required": ["config"]
3653
3676
  }
3654
3677
  },
3678
+ {
3679
+ "description": "type='links' (CnLinkCardsPage) renders config.cards as link cards grouped by config.categories. It MUST declare a non-empty cards list, and every card is a $defs/linkCard, so a card that can go nowhere is refused here and not dropped in silence at render time (link-cards-page).",
3680
+ "if": { "properties": { "type": { "const": "links" } }, "required": ["type"] },
3681
+ "then": {
3682
+ "properties": {
3683
+ "config": {
3684
+ "type": "object",
3685
+ "required": ["cards"],
3686
+ "properties": {
3687
+ "description": { "type": "string" },
3688
+ "emptyLabel": { "type": "string" },
3689
+ "categories": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1 } },
3690
+ "cards": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/linkCard" } }
3691
+ }
3692
+ }
3693
+ },
3694
+ "required": ["config"]
3695
+ }
3696
+ },
3655
3697
  {
3656
3698
  "description": "type='custom' requires _note documenting why decomposition into a standard page type was not feasible. Relaxed by ConductionNL/nextcloud-vue#315: when `component` matches the lib `Cn[A-Z]\\w+` pattern, the component name already documents the choice and _note is optional. When `component` is absent or non-Cn (host-app SFC), _note remains required.",
3657
3699
  "if": {
@@ -27,7 +27,7 @@
27
27
  */
28
28
  export type TPageType =
29
29
  | 'index' | 'detail' | 'dashboard' | 'logs' | 'settings' | 'chat'
30
- | 'files' | 'form' | 'map' | 'roadmap' | 'reports' | 'store' | 'search' | 'wiki'
30
+ | 'files' | 'form' | 'map' | 'roadmap' | 'reports' | 'links' | 'store' | 'search' | 'wiki'
31
31
  | 'flow'
32
32
  /** @deprecated `flows` predates named index sources — use `index` with `config.source: 'flows'`. */
33
33
  | 'flows'
@@ -130,10 +130,15 @@ export function capitalize(str) {
130
130
  *
131
131
  * Array values are serialized with PHP bracket notation (`key[]=a&key[]=b`)
132
132
  * so the Nextcloud/OpenRegister backend parses them as an array — a plain
133
- * repeated `key=a&key=b` collapses to the last value in PHP. Also handles
134
- * _order serialization (JSON.stringify for objects) and skips
133
+ * repeated `key=a&key=b` collapses to the last value in PHP. A plain object
134
+ * under a filter key is a set of operators and goes out in bracket form
135
+ * (`{ deadline: { lt: 'x' } }` becomes `deadline[lt]=x`); a key already
136
+ * written that way (`'deadline[lt]'`) passes through unchanged. Reserved keys
137
+ * that start with `_` keep their JSON form (`_order`). Skips
135
138
  * null/undefined/empty values.
136
139
  *
140
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-a-filter-operator-is-serialised-in-bracket-form
141
+ *
137
142
  * @param {object} params Key-value pairs for query parameters
138
143
  * @return {string} Query string including leading '?' or empty string
139
144
  */
@@ -159,6 +164,27 @@ export function buildQueryString(params = {}) {
159
164
  queryParams.append(arrayKey, String(item))
160
165
  }
161
166
  }
167
+ } else if (typeof value === 'object' && !key.startsWith('_')) {
168
+ // A filter operator: `{ deadline: { lt: '2026-10-06' } }` goes out
169
+ // as `deadline[lt]=2026-10-06`, the form OpenRegister reads. As
170
+ // JSON it reached the database as the literal value and a date
171
+ // column answered 500. Reserved `_` keys (`_order`) keep JSON.
172
+ for (const [op, operand] of Object.entries(value)) {
173
+ if (operand === undefined || operand === null || operand === '') {
174
+ continue
175
+ }
176
+ if (Array.isArray(operand)) {
177
+ for (const item of operand) {
178
+ if (item !== undefined && item !== null && item !== '') {
179
+ queryParams.append(`${key}[${op}][]`, String(item))
180
+ }
181
+ }
182
+ } else if (typeof operand === 'object') {
183
+ queryParams.set(`${key}[${op}]`, JSON.stringify(operand))
184
+ } else {
185
+ queryParams.set(`${key}[${op}]`, String(operand))
186
+ }
187
+ }
162
188
  } else if (typeof value === 'object') {
163
189
  queryParams.set(key, JSON.stringify(value))
164
190
  } else {