@conduction/nextcloud-vue 2.92.0 → 2.94.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.
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js +1 -1
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js +2 -1
- package/dist/esm/components/CnActionsMenu/CnActionsMenu.vue3.js.map +1 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +28 -8
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +37 -2
- package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +57 -4
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +44 -17
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +44 -2
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +140 -59
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue.js +1 -1
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js +139 -3
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js +60 -12
- package/dist/esm/components/CnCountdownWidget/CnCountdownWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +111 -42
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +117 -73
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +34 -39
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +7 -7
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +5 -3
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +6 -4
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +46 -0
- package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js +6 -2
- package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +82 -9
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js +104 -90
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +9 -0
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js.map +1 -0
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +355 -0
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -0
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +462 -0
- package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -0
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +21 -0
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +402 -391
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
- package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +141 -7
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +22 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +20 -14
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue.js +1 -1
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +2 -0
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +15 -5
- package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
- package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js +8 -0
- package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue.js.map +1 -0
- package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js +114 -0
- package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue2.js.map +1 -0
- package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js +45 -0
- package/dist/esm/components/CnPageHeader/CnHeaderTrail.vue3.js.map +1 -0
- package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js +38 -2
- package/dist/esm/components/CnPageHeader/CnPageHeader.vue2.js.map +1 -1
- package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js +12 -2
- package/dist/esm/components/CnPageHeader/CnPageHeader.vue3.js.map +1 -1
- package/dist/esm/components/CnPagination/CnPagination.vue2.js.map +1 -1
- package/dist/esm/components/CnPagination/CnPagination.vue3.js +56 -51
- package/dist/esm/components/CnPagination/CnPagination.vue3.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +3 -3
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue.js +1 -1
- package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js +8 -30
- package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js.map +1 -1
- package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue3.js.map +1 -1
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +24 -0
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js +3 -1
- package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
- package/dist/esm/components/CnTabs/CnTabs.vue.js +1 -1
- package/dist/esm/components/CnTabs/CnTabs.vue2.js +39 -2
- package/dist/esm/components/CnTabs/CnTabs.vue2.js.map +1 -1
- package/dist/esm/components/CnTabs/CnTabs.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +85 -8
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +37 -9
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +9 -0
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js.map +1 -0
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +164 -0
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -0
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +93 -0
- package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -0
- package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue.js +1 -1
- package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js.map +1 -1
- package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js +4 -3
- package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +32 -0
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +31 -20
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
- package/dist/esm/composables/indexSources.js +9 -0
- package/dist/esm/composables/indexSources.js.map +1 -1
- package/dist/esm/l10n/en.json.js +24 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +24 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +1190 -410
- package/dist/esm/utils/dueText.js +49 -8
- package/dist/esm/utils/dueText.js.map +1 -1
- package/dist/esm/utils/featureCatalog.js +135 -0
- package/dist/esm/utils/featureCatalog.js.map +1 -0
- package/dist/esm/utils/fillTemplate.js +74 -3
- package/dist/esm/utils/fillTemplate.js.map +1 -1
- package/dist/esm/utils/headerMeta.js +62 -12
- package/dist/esm/utils/headerMeta.js.map +1 -1
- package/dist/esm/utils/roadmapIssues.js +86 -0
- package/dist/esm/utils/roadmapIssues.js.map +1 -0
- package/dist/esm/utils/userDisplayName.js +47 -0
- package/dist/esm/utils/userDisplayName.js.map +1 -0
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +16229 -13423
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +1190 -410
- package/l10n/en.json +24 -1
- package/l10n/nl.json +24 -1
- package/package.json +1 -1
- package/src/components/CnActionsMenu/CnActionsMenu.vue +3 -1
- package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +39 -1
- package/src/components/CnAppRoot/CnAppRoot.md +2 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +66 -5
- package/src/components/CnChoiceCards/CnChoiceCards.md +27 -0
- package/src/components/CnChoiceCards/CnChoiceCards.vue +168 -3
- package/src/components/CnCountdownWidget/CnCountdownWidget.vue +222 -6
- package/src/components/CnDashboardPage/CnDashboardPage.vue +141 -8
- package/src/components/CnDataTable/CnDataTable.md +1 -0
- package/src/components/CnDataTable/CnDataTable.vue +41 -46
- package/src/components/CnDetailPage/CnDetailPage.vue +5 -3
- package/src/components/CnFactsWidget/CnFactsWidget.vue +6 -4
- package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +51 -1
- package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +98 -13
- package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +1053 -0
- package/src/components/CnFormDialog/CnFormDialog.vue +606 -562
- package/src/components/CnIndexPage/CnIndexPage.vue +144 -7
- package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +23 -2
- package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +5 -0
- package/src/components/CnPageHeader/CnHeaderTrail.vue +140 -0
- package/src/components/CnPageHeader/CnPageHeader.md +11 -0
- package/src/components/CnPageHeader/CnPageHeader.vue +42 -0
- package/src/components/CnPagination/CnPagination.vue +10 -2
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +3 -3
- package/src/components/CnRoadmapTab/CnRoadmapTab.vue +6 -28
- package/src/components/CnSettingsPage/CnSettingsPage.vue +26 -0
- package/src/components/CnTabs/CnTabs.vue +39 -2
- package/src/components/CnTabsWidget/CnTabsWidget.vue +106 -13
- package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +305 -0
- package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +4 -3
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +8 -0
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +43 -2
- package/src/composables/indexSources.js +9 -0
- package/src/css/dialog.css +61 -0
- package/src/css/look-board-detail.css +8 -0
- package/src/css/look-board-index.css +27 -1
- package/src/css/look-board.css +60 -0
- package/src/css/page-header.css +16 -0
- package/src/schemas/app-manifest-v2.schema.json +30 -17
- package/src/utils/dueText.js +49 -8
- package/src/utils/featureCatalog.js +132 -0
- package/src/utils/fillTemplate.js +73 -2
- package/src/utils/headerMeta.js +62 -12
- package/src/utils/roadmapIssues.js +82 -0
- package/src/utils/userDisplayName.js +54 -0
- package/src/utils/validateManifestV2.compiled.js +3 -3
|
@@ -54,11 +54,14 @@
|
|
|
54
54
|
class="cn-widget-wrapper__icon" />
|
|
55
55
|
<!-- An eyebrow (`eyebrow`) is a muted line above the title:
|
|
56
56
|
"Saldo per week" above "Prognose 13 weken". -->
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
<!-- A subtitle (`subtitle`) is a muted line under the title:
|
|
58
|
+
"Dit kwartaal, beslist binnen de termijn". -->
|
|
59
|
+
<div v-if="resolvedEyebrow || resolvedSubtitle" class="cn-widget-wrapper__title-stack">
|
|
60
|
+
<span v-if="resolvedEyebrow" class="cn-widget-wrapper__eyebrow" data-testid="cn-widget-wrapper-eyebrow">{{ resolvedEyebrow }}</span>
|
|
59
61
|
<h3 :id="titleId" class="cn-widget-wrapper__title">
|
|
60
62
|
{{ resolvedTitle }}
|
|
61
63
|
</h3>
|
|
64
|
+
<span v-if="resolvedSubtitle" class="cn-widget-wrapper__subtitle" data-testid="cn-widget-wrapper-subtitle">{{ resolvedSubtitle }}</span>
|
|
62
65
|
</div>
|
|
63
66
|
<h3 v-else :id="titleId" class="cn-widget-wrapper__title">
|
|
64
67
|
{{ resolvedTitle }}
|
|
@@ -225,6 +228,18 @@ export default {
|
|
|
225
228
|
CnActionsMenu,
|
|
226
229
|
},
|
|
227
230
|
|
|
231
|
+
provide() {
|
|
232
|
+
return {
|
|
233
|
+
/**
|
|
234
|
+
* Whether this card draws a title row, so a widget inside it can
|
|
235
|
+
* leave out a heading of its own (nc-widget-header).
|
|
236
|
+
*
|
|
237
|
+
* @return {boolean}
|
|
238
|
+
*/
|
|
239
|
+
cnWidgetTitled: () => this.titleVisible && Boolean(this.displayTitle),
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
|
|
228
243
|
inject: {
|
|
229
244
|
/**
|
|
230
245
|
* Host translate function provided by CnAppRoot as
|
|
@@ -252,6 +267,17 @@ export default {
|
|
|
252
267
|
default: '',
|
|
253
268
|
},
|
|
254
269
|
|
|
270
|
+
/**
|
|
271
|
+
* A muted line under the title, such as the period a chart covers.
|
|
272
|
+
* Run through the host translate function. Empty draws none.
|
|
273
|
+
*
|
|
274
|
+
* @spec openspec/changes/page-header-breadcrumb/specs/page-header/spec.md#requirement-a-dashboard-widget-card-can-carry-a-line-under-its-title
|
|
275
|
+
*/
|
|
276
|
+
subtitle: {
|
|
277
|
+
type: String,
|
|
278
|
+
default: '',
|
|
279
|
+
},
|
|
280
|
+
|
|
255
281
|
/** Whether to show the header with title */
|
|
256
282
|
showTitle: {
|
|
257
283
|
type: Boolean,
|
|
@@ -721,6 +747,15 @@ export default {
|
|
|
721
747
|
return this.eyebrow ? this.effectiveTranslate(this.eyebrow) : ''
|
|
722
748
|
},
|
|
723
749
|
|
|
750
|
+
/**
|
|
751
|
+
* The subtitle run through the host translate function.
|
|
752
|
+
*
|
|
753
|
+
* @return {string}
|
|
754
|
+
*/
|
|
755
|
+
resolvedSubtitle() {
|
|
756
|
+
return this.subtitle ? this.effectiveTranslate(this.subtitle) : ''
|
|
757
|
+
},
|
|
758
|
+
|
|
724
759
|
/**
|
|
725
760
|
* The RENDERED widget title — `displayTitle` run through the host
|
|
726
761
|
* translate function. Kept separate from `displayTitle` on purpose:
|
|
@@ -1163,6 +1198,12 @@ export default {
|
|
|
1163
1198
|
min-width: 0;
|
|
1164
1199
|
}
|
|
1165
1200
|
|
|
1201
|
+
.cn-widget-wrapper__subtitle {
|
|
1202
|
+
font-size: 13px;
|
|
1203
|
+
line-height: 1.3;
|
|
1204
|
+
color: var(--color-text-maxcontrast);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1166
1207
|
.cn-widget-wrapper__eyebrow {
|
|
1167
1208
|
font-size: 13px;
|
|
1168
1209
|
line-height: 1.3;
|
|
@@ -267,6 +267,15 @@ function tasksSource() {
|
|
|
267
267
|
*/
|
|
268
268
|
loading: () => !!store.loading,
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Count the inbox for a config without touching the rows on screen,
|
|
272
|
+
* for a quick-filter tab with `showCount`.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} config The merged config, as for `load`.
|
|
275
|
+
* @return {Promise<number>} The datastore total.
|
|
276
|
+
*/
|
|
277
|
+
count: async (config) => (await store.fetchFor({ ...(config || {}), limit: 1 })).total,
|
|
278
|
+
|
|
270
279
|
columns: [
|
|
271
280
|
{ key: 'title', label: t('nextcloud-vue', 'Task') },
|
|
272
281
|
{ key: 'subjectLabel', label: t('nextcloud-vue', 'Subject') },
|
package/src/css/dialog.css
CHANGED
|
@@ -203,3 +203,64 @@
|
|
|
203
203
|
.cn-dialog__confirm-pending {
|
|
204
204
|
opacity: 0.45;
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
/* On a phone the board dialog is a bottom sheet (board PqMobiel): full width,
|
|
208
|
+
anchored to the bottom, as tall as its content up to the screen less the
|
|
209
|
+
header, a 16px top radius and a grab bar. The footer buttons share the row. */
|
|
210
|
+
@media (max-width: 600px) {
|
|
211
|
+
.modal-mask.cn-dialog-board .modal-wrapper .modal-container,
|
|
212
|
+
.modal-mask.cn-dialog-board .modal-wrapper--normal > .modal-container {
|
|
213
|
+
position: absolute;
|
|
214
|
+
inset: auto 0 0;
|
|
215
|
+
width: 100%;
|
|
216
|
+
max-width: 100%;
|
|
217
|
+
height: auto;
|
|
218
|
+
max-height: calc(100% - var(--header-height, 50px));
|
|
219
|
+
border-radius: 16px 16px 0 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.modal-mask.cn-dialog-board .modal-container::before {
|
|
223
|
+
content: '';
|
|
224
|
+
display: block;
|
|
225
|
+
flex: none;
|
|
226
|
+
width: 40px;
|
|
227
|
+
height: 4px;
|
|
228
|
+
margin: 8px auto 0;
|
|
229
|
+
border-radius: 2px;
|
|
230
|
+
background: var(--color-border-dark);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.modal-mask.cn-dialog-board .modal-container {
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.cn-dialog-board .dialog {
|
|
238
|
+
height: auto;
|
|
239
|
+
min-height: 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.cn-dialog-board .dialog__content {
|
|
243
|
+
flex: 0 1 auto;
|
|
244
|
+
overflow-y: auto;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.cn-dialog-board .dialog__actions > .button-vue,
|
|
248
|
+
.cn-dialog-board .dialog__actions > * {
|
|
249
|
+
flex: 1 1 0;
|
|
250
|
+
min-height: 44px;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* Above a phone the boards top-align a dialog (DqNieuweZaak, DqUwWachtrij,
|
|
255
|
+
DqVolgendeStap: the top edge sits about 90px down at 1440 wide) instead of
|
|
256
|
+
centring it. `--cn-dialog-top` moves it. */
|
|
257
|
+
@media (min-width: 601px) {
|
|
258
|
+
.modal-mask.cn-dialog-board .modal-wrapper {
|
|
259
|
+
align-items: flex-start;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.modal-mask.cn-dialog-board .modal-wrapper .modal-container {
|
|
263
|
+
margin-top: var(--cn-dialog-top, 88px);
|
|
264
|
+
max-height: calc(100% - var(--cn-dialog-top, 88px) - 24px);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -660,3 +660,11 @@
|
|
|
660
660
|
.cn-look-board:has(.cn-detail-page) > .app-sidebar__toggle {
|
|
661
661
|
display: none;
|
|
662
662
|
}
|
|
663
|
+
|
|
664
|
+
/* Below 768px the header is a column (detail-page.css), where the 320px basis
|
|
665
|
+
above would be a 320px HEIGHT: the title sat in a band of white on a phone. */
|
|
666
|
+
@media (max-width: 768px) {
|
|
667
|
+
.cn-look-board .cn-detail-page__header--board > :first-child {
|
|
668
|
+
flex: none;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
@@ -481,10 +481,11 @@
|
|
|
481
481
|
border-radius: 0;
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
/* The header row is near-white on the boards (#fafbfc), not the hover grey. */
|
|
484
485
|
.cn-look-board .cn-table-container--board .cn-data-table th {
|
|
485
486
|
padding: 12px 16px;
|
|
486
487
|
border-bottom: 0;
|
|
487
|
-
background: var(--color-background-hover);
|
|
488
|
+
background: var(--cn-board-table-head-bg, color-mix(in srgb, var(--color-background-hover) 40%, var(--color-main-background)));
|
|
488
489
|
color: var(--color-text-maxcontrast);
|
|
489
490
|
font-weight: 600;
|
|
490
491
|
}
|
|
@@ -542,6 +543,15 @@
|
|
|
542
543
|
max-width: calc(100% - 26px);
|
|
543
544
|
}
|
|
544
545
|
|
|
546
|
+
/* The board's funnel: a 22px button 4px after the sort chevron. */
|
|
547
|
+
.cn-look-board .cn-table-container--board .cn-data-table th .cn-table-header__filter {
|
|
548
|
+
width: 22px;
|
|
549
|
+
min-width: 22px;
|
|
550
|
+
height: 22px;
|
|
551
|
+
min-height: 22px;
|
|
552
|
+
margin-inline-start: 4px;
|
|
553
|
+
}
|
|
554
|
+
|
|
545
555
|
/* The selection column: 18px checkboxes, 28px of content after 16px of padding.
|
|
546
556
|
The box model is set with `!important` and the widths include the padding:
|
|
547
557
|
Nextcloud's server.css sets `box-sizing: border-box` on everything under
|
|
@@ -747,6 +757,14 @@
|
|
|
747
757
|
padding: 0 12px;
|
|
748
758
|
}
|
|
749
759
|
|
|
760
|
+
/* The inert Next of a single page: a faint outline, muted text. */
|
|
761
|
+
.cn-look-board .cn-pagination--board .cn-pagination__board-link--disabled {
|
|
762
|
+
display: inline-flex;
|
|
763
|
+
align-items: center;
|
|
764
|
+
color: var(--color-text-maxcontrast);
|
|
765
|
+
cursor: default;
|
|
766
|
+
}
|
|
767
|
+
|
|
750
768
|
.cn-look-board .cn-pagination--board .cn-pagination__board-link--current {
|
|
751
769
|
border-color: var(--color-primary-element);
|
|
752
770
|
background: var(--color-primary-element);
|
|
@@ -1028,3 +1046,11 @@
|
|
|
1028
1046
|
padding: 0 12px;
|
|
1029
1047
|
border-radius: 8px;
|
|
1030
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
/* The sidebar's own open toggle (NcAppSidebar's `.app-sidebar__toggle`, drawn
|
|
1051
|
+
when the sidebar is closed) sits over the content's top-right corner, which
|
|
1052
|
+
on an index page is the primary header button: a stray dark square on
|
|
1053
|
+
"New contact". The board draws none; the Filter button opens the sidebar. */
|
|
1054
|
+
.cn-look-board:has(.cn-index-page) > .app-sidebar__toggle {
|
|
1055
|
+
display: none;
|
|
1056
|
+
}
|
package/src/css/look-board.css
CHANGED
|
@@ -591,6 +591,66 @@
|
|
|
591
591
|
max-width: none;
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
+
/* A Nextcloud text field or text area in a board settings card has its label
|
|
595
|
+
above the control (14px/600), as the boards draw it (PqBeheer Leadbeheer,
|
|
596
|
+
PqBeheerKassa Btw), instead of floating inside the border. The label stays
|
|
597
|
+
the field's own `<label for>`; it moves out of the control and the field
|
|
598
|
+
makes room for it. With the label outside, the placeholder shows at rest.
|
|
599
|
+
@spec openspec/changes/settings-section-board-fields/specs/settings-board-look/spec.md#requirement-a-settings-card-labels-its-text-fields-above-the-control */
|
|
600
|
+
.cn-look-board .settings-section:not(.cn-look-nextcloud *) .input-field:has(.input-field__label),
|
|
601
|
+
.cn-look-board .settings-section:not(.cn-look-nextcloud *) .textarea:has(.textarea__label) {
|
|
602
|
+
margin-block-start: 26px;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.cn-look-board .settings-section:not(.cn-look-nextcloud *) .input-field__input + .input-field__label,
|
|
606
|
+
.cn-look-board .settings-section:not(.cn-look-nextcloud *) .textarea__input + .textarea__label {
|
|
607
|
+
inset-block-start: auto;
|
|
608
|
+
inset-block-end: calc(100% + 4px);
|
|
609
|
+
inset-inline: 0 auto;
|
|
610
|
+
max-width: 100%;
|
|
611
|
+
margin: 0;
|
|
612
|
+
padding: 0;
|
|
613
|
+
background: none;
|
|
614
|
+
color: var(--color-main-text);
|
|
615
|
+
font-size: 14px;
|
|
616
|
+
font-weight: 600;
|
|
617
|
+
line-height: 20px;
|
|
618
|
+
pointer-events: auto;
|
|
619
|
+
transition: none;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.cn-look-board .settings-section:not(.cn-look-nextcloud *) .input-field:has(.input-field__label) .input-field__input:not(:focus)::placeholder,
|
|
623
|
+
.cn-look-board .settings-section:not(.cn-look-nextcloud *) .textarea__input:not(:focus):not(.textarea__input--label-outside)::placeholder {
|
|
624
|
+
opacity: 1 !important;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/* The support card at the end of an admin page (`supportPosition: "end"`,
|
|
628
|
+
board PqBeheer): one row, the title then the lines, wrapping in a narrow
|
|
629
|
+
window, with less padding than a settings card.
|
|
630
|
+
@spec openspec/changes/admin-shell-support-end/specs/settings-board-look/spec.md#requirement-support-can-be-its-own-card-at-the-end */
|
|
631
|
+
.cn-look-board .settings-section.cn-admin-settings-shell__support:not(.cn-look-nextcloud *) {
|
|
632
|
+
flex-direction: row !important;
|
|
633
|
+
flex-wrap: wrap;
|
|
634
|
+
align-items: center;
|
|
635
|
+
gap: 10px 24px;
|
|
636
|
+
padding: 16px 24px;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.cn-look-board .cn-admin-settings-shell__support:not(.cn-look-nextcloud *) .settings-section__name {
|
|
640
|
+
font-size: 16px;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.cn-look-board .cn-admin-settings-shell__support:not(.cn-look-nextcloud *) .cn-settings-section__content {
|
|
644
|
+
display: flex;
|
|
645
|
+
flex-wrap: wrap;
|
|
646
|
+
gap: 10px 24px;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.cn-look-board .cn-admin-settings-shell__support:not(.cn-look-nextcloud *) .cn-admin-settings-shell__support-line {
|
|
650
|
+
margin: 0;
|
|
651
|
+
font-size: 14px;
|
|
652
|
+
}
|
|
653
|
+
|
|
594
654
|
/* The section footer is the save row: the hairline replaces its own rule. */
|
|
595
655
|
.cn-look-board .settings-section .cn-settings-section__footer {
|
|
596
656
|
margin: 0;
|
package/src/css/page-header.css
CHANGED
|
@@ -61,3 +61,19 @@
|
|
|
61
61
|
white-space: nowrap;
|
|
62
62
|
border: 0;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/* The breadcrumb and meta line under the title (CnHeaderTrail). In the board
|
|
66
|
+
look it takes the detail header row's styles; here it only lays the crumbs,
|
|
67
|
+
the dot and the note out on one line. */
|
|
68
|
+
.cn-header-trail {
|
|
69
|
+
align-items: center;
|
|
70
|
+
color: var(--color-text-maxcontrast);
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-wrap: wrap;
|
|
73
|
+
gap: var(--default-grid-baseline);
|
|
74
|
+
margin-bottom: var(--default-grid-baseline);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.cn-header-trail .cn-detail-page__breadcrumbs {
|
|
78
|
+
margin-bottom: 0;
|
|
79
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
|
|
4
4
|
"title": "Conduction App Manifest v2",
|
|
5
5
|
"description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.115.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -533,6 +533,21 @@
|
|
|
533
533
|
}
|
|
534
534
|
},
|
|
535
535
|
"$defs": {
|
|
536
|
+
"breadcrumbCrumb": {
|
|
537
|
+
"type": "object",
|
|
538
|
+
"additionalProperties": false,
|
|
539
|
+
"required": ["label"],
|
|
540
|
+
"description": "One breadcrumb crumb (config.breadcrumb).",
|
|
541
|
+
"properties": {
|
|
542
|
+
"label": { "type": "string", "minLength": 1 },
|
|
543
|
+
"route": { "type": "string" },
|
|
544
|
+
"params": { "type": "object", "additionalProperties": true },
|
|
545
|
+
"href": { "type": "string" },
|
|
546
|
+
"icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." },
|
|
547
|
+
"currentField": { "type": "string", "minLength": 1, "description": "A field of the record (dotted path, e.g. `identifier`) whose value is the current crumb instead of the display name; an empty value falls back to the display name (zuiddrecht-pixel-gaps-3)." },
|
|
548
|
+
"separator": { "type": "string", "minLength": 1, "maxLength": 3, "description": "Text drawn between the crumbs (e.g. `/`) instead of the chevron (zuiddrecht-pixel-gaps-3)." }
|
|
549
|
+
}
|
|
550
|
+
},
|
|
536
551
|
"rowIndicator": {
|
|
537
552
|
"type": "object",
|
|
538
553
|
"additionalProperties": false,
|
|
@@ -4259,7 +4274,7 @@
|
|
|
4259
4274
|
"headerMeta": {
|
|
4260
4275
|
"type": "string",
|
|
4261
4276
|
"minLength": 1,
|
|
4262
|
-
"description": "type='detail', board look: the meta line on row 2 of the header, after the breadcrumb and a middle dot (screens-detail-page-parity). A field template such as `\"via {channel}\"`: every `{path}` is read from the record, and a line with an empty value is dropped. Maps to CnDetailPage `headerMeta`."
|
|
4277
|
+
"description": "type='detail', board look: the meta line on row 2 of the header, after the breadcrumb and a middle dot (screens-detail-page-parity). A field template such as `\"via {channel}\"`: every `{path}` is read from the record, and a line with an empty value is dropped. Maps to CnDetailPage `headerMeta`. type='index' | 'dashboard' | 'settings' (page-header-breadcrumb): the note after the breadcrumb and a middle dot under the title; on an index page `{total}` and `{shown}` are the list's counts."
|
|
4263
4278
|
},
|
|
4264
4279
|
"tabsLabel": {
|
|
4265
4280
|
"type": "string",
|
|
@@ -4285,10 +4300,16 @@
|
|
|
4285
4300
|
"greeting": {
|
|
4286
4301
|
"oneOf": [
|
|
4287
4302
|
{ "type": "boolean" },
|
|
4288
|
-
{ "type": "string", "enum": ["first", "full"] }
|
|
4303
|
+
{ "type": "string", "enum": ["first", "full", "date"] },
|
|
4304
|
+
{
|
|
4305
|
+
"type": "object",
|
|
4306
|
+
"required": ["label"],
|
|
4307
|
+
"additionalProperties": false,
|
|
4308
|
+
"properties": { "label": { "type": "string", "minLength": 1, "description": "i18n key drawn where the greeting would be." } }
|
|
4309
|
+
}
|
|
4289
4310
|
],
|
|
4290
4311
|
"default": false,
|
|
4291
|
-
"description": "type='dashboard': greet the reader in the header subtitle. `true` or \"first\" writes \"Good afternoon, Pieter · Monday 5 October 2026\", \"full\" uses the whole display name; the page `description` follows after another middle dot (screens-dashboard-greeting-header). Maps to CnDashboardPage `greeting`."
|
|
4312
|
+
"description": "type='dashboard': greet the reader in the header subtitle. `true` or \"first\" writes \"Good afternoon, Pieter · Monday 5 October 2026\", \"full\" uses the whole display name; the page `description` follows after another middle dot (screens-dashboard-greeting-header). Since 2.111.0 \"date\" leads with the date alone (\"Tuesday 7 October 2026 · ...\") and `{ \"label\": \"Customer contact\" }` puts the translated label where the greeting would be (dashboard-greeting-label). Maps to CnDashboardPage `greeting`."
|
|
4292
4313
|
},
|
|
4293
4314
|
"viewLinks": {
|
|
4294
4315
|
"type": "array",
|
|
@@ -4493,19 +4514,11 @@
|
|
|
4493
4514
|
"description": "type='detail': `false` drops the type eyebrow (the type label above the record name) once the record resolves, for a header that says the type in a pill instead (zuiddrecht-pixel-gaps). Maps to CnDetailPage `showTypeEyebrow`."
|
|
4494
4515
|
},
|
|
4495
4516
|
"breadcrumb": {
|
|
4496
|
-
"
|
|
4497
|
-
"
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
"label": { "type": "string", "minLength": 1 },
|
|
4502
|
-
"route": { "type": "string" },
|
|
4503
|
-
"params": { "type": "object", "additionalProperties": true },
|
|
4504
|
-
"href": { "type": "string" },
|
|
4505
|
-
"icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name (e.g. `Home`) drawn as the first crumb instead of the label, which then becomes its accessible name (zuiddrecht-pixel-gaps-2). Without it the label shows as text." },
|
|
4506
|
-
"currentField": { "type": "string", "minLength": 1, "description": "A field of the record (dotted path, e.g. `identifier`) whose value is the current crumb instead of the display name; an empty value falls back to the display name (zuiddrecht-pixel-gaps-3)." },
|
|
4507
|
-
"separator": { "type": "string", "minLength": 1, "maxLength": 3, "description": "Text drawn between the crumbs (e.g. `/`) instead of the chevron (zuiddrecht-pixel-gaps-3)." }
|
|
4508
|
-
}
|
|
4517
|
+
"description": "A breadcrumb line. type='detail' (zuiddrecht-pixel-gaps): one crumb naming the list the record belongs to (`route` + optional `params`, or `href`); the record's display name follows as the current crumb. Maps to CnDetailPage `breadcrumb`. type='index' | 'dashboard' | 'settings' (page-header-breadcrumb): one crumb or a list, drawn under the title in the same row style; the page title follows as the current crumb. Maps to the page's `breadcrumb`.",
|
|
4518
|
+
"anyOf": [
|
|
4519
|
+
{ "$ref": "#/$defs/breadcrumbCrumb" },
|
|
4520
|
+
{ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/breadcrumbCrumb" } }
|
|
4521
|
+
]
|
|
4509
4522
|
},
|
|
4510
4523
|
"headerCard": {
|
|
4511
4524
|
"type": "boolean",
|
package/src/utils/dueText.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* "3 dagen te laat" in the danger colour, "Vandaag", the short date for a
|
|
8
8
|
* day still to come ("morgen", "20 okt"), and a configured text when the
|
|
9
|
-
* field is empty ("Geen deadline").
|
|
9
|
+
* field is empty ("Geen deadline"). A day to come can read as a count instead
|
|
10
|
+
* ("nog 4 dagen", "nog 8 weken"). One formatter for record-list rows and
|
|
10
11
|
* table cells, built on the relative date (`formatRelativeDate`).
|
|
11
12
|
*
|
|
12
13
|
* @module utils/dueText
|
|
@@ -23,8 +24,12 @@ import { parseDateValue } from './dateVariant.js'
|
|
|
23
24
|
* Config (all optional): `todayLabel` (default "Due today"), `overdueLabel`
|
|
24
25
|
* `{ one, other }` with `{count}` (default "{count} day overdue" / "{count} days
|
|
25
26
|
* overdue"), `emptyText` (shown when the field is empty; without it an empty
|
|
26
|
-
* field reads as nothing)
|
|
27
|
-
*
|
|
27
|
+
* field reads as nothing), `upcomingLabel` `{ one, other }` with `{count}`
|
|
28
|
+
* (a day to come reads as the days left instead of the date), and
|
|
29
|
+
* `upcomingWeeksLabel` `{ one, other }` (from `upcomingWeeksFrom` days left,
|
|
30
|
+
* default 35, it reads the weeks left, rounded). Texts go through `translate`
|
|
31
|
+
* before `{count}` is filled. Tones: `danger` (overdue), `today`, `upcoming`,
|
|
32
|
+
* `empty`.
|
|
28
33
|
*
|
|
29
34
|
* @param {unknown} value A Date, a timestamp or an ISO string.
|
|
30
35
|
* @param {object} [config] The `due` config.
|
|
@@ -41,10 +46,9 @@ export function resolveDue(value, config, translate = (text) => text, now = new
|
|
|
41
46
|
}
|
|
42
47
|
const days = ageInDays(date, now)
|
|
43
48
|
if (days > 0) {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
return { text: template.replace(/\{count\}/g, String(days)), tone: 'danger' }
|
|
49
|
+
const overdue = pluralLabel(cfg.overdueLabel, days, translate)
|
|
50
|
+
if (overdue !== null) {
|
|
51
|
+
return { text: overdue, tone: 'danger' }
|
|
48
52
|
}
|
|
49
53
|
return { text: n('nextcloud-vue', '{count} day overdue', '{count} days overdue', days, { count: days }), tone: 'danger' }
|
|
50
54
|
}
|
|
@@ -52,5 +56,42 @@ export function resolveDue(value, config, translate = (text) => text, now = new
|
|
|
52
56
|
const label = typeof cfg.todayLabel === 'string' && cfg.todayLabel !== '' ? translate(cfg.todayLabel) : t('nextcloud-vue', 'Due today')
|
|
53
57
|
return { text: label, tone: 'today' }
|
|
54
58
|
}
|
|
55
|
-
return { text: formatRelativeDate(date, { now }), tone: 'upcoming' }
|
|
59
|
+
return { text: upcomingText(-days, cfg, translate) ?? formatRelativeDate(date, { now }), tone: 'upcoming' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The days or weeks left before a date to come, or null without `upcomingLabel`.
|
|
64
|
+
*
|
|
65
|
+
* @param {number} left Whole days left, at least 1.
|
|
66
|
+
* @param {object} cfg The `due` config.
|
|
67
|
+
* @param {(text: string) => string} translate The host's translate.
|
|
68
|
+
* @return {string|null} The text, or null.
|
|
69
|
+
* @spec openspec/changes/due-text-upcoming-count/specs/due-text-upcoming-count/spec.md#requirement-a-due-date-to-come-can-read-as-the-time-left
|
|
70
|
+
*/
|
|
71
|
+
function upcomingText(left, cfg, translate) {
|
|
72
|
+
const from = Number(cfg.upcomingWeeksFrom)
|
|
73
|
+
const weeksFrom = Number.isFinite(from) && from > 0 ? from : 35
|
|
74
|
+
if (left >= weeksFrom) {
|
|
75
|
+
const weeks = pluralLabel(cfg.upcomingWeeksLabel, Math.round(left / 7), translate)
|
|
76
|
+
if (weeks !== null) {
|
|
77
|
+
return weeks
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return pluralLabel(cfg.upcomingLabel, left, translate)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A `{ one, other }` label with `{count}` filled, or null when none is configured.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} labels The configured label pair.
|
|
87
|
+
* @param {number} count The count.
|
|
88
|
+
* @param {(text: string) => string} translate The host's translate.
|
|
89
|
+
* @return {string|null} The text, or null.
|
|
90
|
+
*/
|
|
91
|
+
function pluralLabel(labels, count, translate) {
|
|
92
|
+
if (!labels || typeof labels !== 'object' || !(labels.one || labels.other)) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
const template = translate((count === 1 ? labels.one : labels.other) || labels.other || labels.one)
|
|
96
|
+
return template.replace(/\{count\}/g, String(count))
|
|
56
97
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* featureCatalog: the feature list of the Features and roadmap page, read the
|
|
6
|
+
* way the board look draws it. A feature carries a status (available, beta,
|
|
7
|
+
* in progress or planned), an optional category and optional per-language
|
|
8
|
+
* titles (`title_nl`, `summary_nl`) next to the English ones.
|
|
9
|
+
*
|
|
10
|
+
* @module utils/featureCatalog
|
|
11
|
+
* @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The statuses, in the order the filter and the captions list them. */
|
|
15
|
+
export const FEATURE_STATUSES = Object.freeze(['available', 'beta', 'in_progress', 'planned'])
|
|
16
|
+
|
|
17
|
+
/** The badge variant of each status. */
|
|
18
|
+
export const FEATURE_STATUS_VARIANTS = Object.freeze({
|
|
19
|
+
available: 'success',
|
|
20
|
+
beta: 'warning',
|
|
21
|
+
in_progress: 'primary',
|
|
22
|
+
planned: 'purple',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const STATUS_ALIASES = Object.freeze({
|
|
26
|
+
available: 'available',
|
|
27
|
+
stable: 'available',
|
|
28
|
+
done: 'available',
|
|
29
|
+
released: 'available',
|
|
30
|
+
shipped: 'available',
|
|
31
|
+
beta: 'beta',
|
|
32
|
+
preview: 'beta',
|
|
33
|
+
in_progress: 'in_progress',
|
|
34
|
+
'in-progress': 'in_progress',
|
|
35
|
+
inprogress: 'in_progress',
|
|
36
|
+
building: 'in_progress',
|
|
37
|
+
development: 'in_progress',
|
|
38
|
+
planned: 'planned',
|
|
39
|
+
roadmap: 'planned',
|
|
40
|
+
proposed: 'planned',
|
|
41
|
+
soon: 'planned',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The status key of a feature. A feature without a status is one the app
|
|
46
|
+
* ships, as every entry of the list was before statuses existed.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} raw The feature's `status`.
|
|
49
|
+
* @return {string} One of FEATURE_STATUSES.
|
|
50
|
+
* @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-a-feature-has-one-of-four-statuses
|
|
51
|
+
*/
|
|
52
|
+
export function featureStatus(raw) {
|
|
53
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
54
|
+
return 'available'
|
|
55
|
+
}
|
|
56
|
+
return STATUS_ALIASES[raw.trim().toLowerCase()] || 'available'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A text field of a feature in the reader's language: `title_nl` for a Dutch
|
|
61
|
+
* reader when the feature has one, else `title`.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} feature The feature.
|
|
64
|
+
* @param {string} field `title` or `summary`.
|
|
65
|
+
* @param {string} [language] The reader's language tag (`nl`, `nl_NL`, `de-DE`).
|
|
66
|
+
* @return {string} The text, or ''.
|
|
67
|
+
* @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-a-feature-reads-in-the-readers-language
|
|
68
|
+
*/
|
|
69
|
+
export function featureText(feature, field, language = '') {
|
|
70
|
+
if (!feature || typeof feature !== 'object') {
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
const base = String(language || '').split(/[-_]/)[0].toLowerCase()
|
|
74
|
+
const own = base ? feature[`${field}_${base}`] : undefined
|
|
75
|
+
if (typeof own === 'string' && own.trim() !== '') {
|
|
76
|
+
return own
|
|
77
|
+
}
|
|
78
|
+
return typeof feature[field] === 'string' ? feature[field] : ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How many features carry each status.
|
|
83
|
+
*
|
|
84
|
+
* @param {Array<{status: string}>} features Features with a normalised status.
|
|
85
|
+
* @return {{all: number, available: number, beta: number, in_progress: number, planned: number}} The counts.
|
|
86
|
+
* @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-the-status-filter-shows-a-count-per-status
|
|
87
|
+
*/
|
|
88
|
+
export function countByStatus(features) {
|
|
89
|
+
const counts = { all: features.length, available: 0, beta: 0, in_progress: 0, planned: 0 }
|
|
90
|
+
for (const feature of features) {
|
|
91
|
+
counts[feature.status] += 1
|
|
92
|
+
}
|
|
93
|
+
return counts
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The features in the reader's language, grouped by category. Groups follow
|
|
98
|
+
* the order of `categories`; features without a known category come first,
|
|
99
|
+
* under no caption. Inside a group the features keep their given order.
|
|
100
|
+
*
|
|
101
|
+
* @param {Array<object>} features The raw features.
|
|
102
|
+
* @param {object} [categories] Category key to caption.
|
|
103
|
+
* @param {string} [language] The reader's language tag.
|
|
104
|
+
* @return {Array<{key: string, label: string, features: Array<object>}>} The groups; a group without features is left out.
|
|
105
|
+
* @spec openspec/changes/screens-roadmap-board-look/specs/features-roadmap-board-look/spec.md#requirement-features-are-grouped-by-category
|
|
106
|
+
*/
|
|
107
|
+
export function groupFeatures(features, categories = {}, language = '') {
|
|
108
|
+
const captions = categories && typeof categories === 'object' && !Array.isArray(categories) ? categories : {}
|
|
109
|
+
const groups = [{ key: '', label: '', features: [] }]
|
|
110
|
+
const byKey = { '': groups[0] }
|
|
111
|
+
for (const [key, label] of Object.entries(captions)) {
|
|
112
|
+
const group = { key, label: String(label), features: [] }
|
|
113
|
+
groups.push(group)
|
|
114
|
+
byKey[key] = group
|
|
115
|
+
}
|
|
116
|
+
for (const raw of Array.isArray(features) ? features : []) {
|
|
117
|
+
if (!raw || typeof raw !== 'object') {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const feature = {
|
|
121
|
+
slug: String(raw.slug || raw.id || ''),
|
|
122
|
+
title: featureText(raw, 'title', language) || String(raw.slug || ''),
|
|
123
|
+
summary: featureText(raw, 'summary', language),
|
|
124
|
+
status: featureStatus(raw.status),
|
|
125
|
+
expected: typeof raw.expected === 'string' ? raw.expected : '',
|
|
126
|
+
docsUrl: typeof raw.docsUrl === 'string' ? raw.docsUrl : '',
|
|
127
|
+
}
|
|
128
|
+
const group = byKey[typeof raw.category === 'string' ? raw.category : ''] || byKey['']
|
|
129
|
+
group.features.push(feature)
|
|
130
|
+
}
|
|
131
|
+
return groups.filter((group) => group.features.length > 0)
|
|
132
|
+
}
|