@conduction/nextcloud-vue 0.1.0-beta.16 → 0.1.0-beta.18

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/l10n/en.json CHANGED
@@ -370,6 +370,7 @@
370
370
  "The 'admin' group always has full access (cannot be changed)": "The 'admin' group always has full access (cannot be changed)",
371
371
  "The following items will be permanently deleted. Remove any items you want to keep.": "The following items will be permanently deleted. Remove any items you want to keep.",
372
372
  "The object owner always has full access": "The object owner always has full access",
373
+ "This field has been changed — save to apply": "This field has been changed — save to apply",
373
374
  "Time": "Time",
374
375
  "Title": "Title",
375
376
  "Title *": "Title *",
@@ -402,6 +403,7 @@
402
403
  "Widget not available": "Widget not available",
403
404
  "Write a note...": "Write a note...",
404
405
  "Write back": "Write back",
406
+ "You have unsaved changes. Save to apply them.": "You have unsaved changes. Save to apply them.",
405
407
  "{name} (Copy)": "{name} (Copy)",
406
408
  "{name} - Copy": "{name} - Copy",
407
409
  "{title} saved successfully.": "{title} saved successfully.",
package/l10n/nl.json CHANGED
@@ -368,6 +368,7 @@
368
368
  "The 'admin' group always has full access (cannot be changed)": "De 'admin'-groep heeft altijd volledige toegang (kan niet worden gewijzigd)",
369
369
  "The following items will be permanently deleted. Remove any items you want to keep.": "De volgende items worden permanent verwijderd. Haal items die u wilt bewaren uit de selectie.",
370
370
  "The object owner always has full access": "De eigenaar van het object heeft altijd volledige toegang",
371
+ "This field has been changed — save to apply": "Dit veld is gewijzigd — sla op om toe te passen",
371
372
  "Time": "Tijd",
372
373
  "Title": "Titel",
373
374
  "Title *": "Titel *",
@@ -400,6 +401,7 @@
400
401
  "Widget not available": "Widget niet beschikbaar",
401
402
  "Write a note...": "Schrijf een notitie...",
402
403
  "Write back": "Terugschrijven",
404
+ "You have unsaved changes. Save to apply them.": "U heeft niet-opgeslagen wijzigingen. Sla op om ze toe te passen.",
403
405
  "{name} (Copy)": "{name} (Kopie)",
404
406
  "{name} - Copy": "{name} - Kopie",
405
407
  "{title} saved successfully.": "{title} succesvol opgeslagen.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.18",
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
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -1,125 +1,135 @@
1
1
  <template>
2
- <div class="cn-advanced-form-dialog__table-container">
3
- <table class="cn-advanced-form-dialog__table">
4
- <thead>
5
- <tr class="cn-advanced-form-dialog__table-row">
6
- <th class="cn-advanced-form-dialog__table-col-constrained">
7
- {{ t('nextcloud-vue', 'Property') }}
8
- </th>
9
- <th class="cn-advanced-form-dialog__table-col-expanded">
10
- {{ t('nextcloud-vue', 'Value') }}
11
- </th>
12
- <th
13
- v-if="hasRowActionsSlot"
14
- class="cn-advanced-form-dialog__table-col-actions">
15
- <slot name="row-actions-header" />
16
- </th>
17
- </tr>
18
- </thead>
19
- <tbody>
20
- <tr
21
- v-for="([key, value]) in objectProperties"
22
- :key="key"
23
- class="cn-advanced-form-dialog__table-row"
24
- :class="{
25
- 'cn-advanced-form-dialog__table-row--selected': selectedProperty === key,
26
- 'cn-advanced-form-dialog__table-row--edited': isValueChanged(key),
27
- 'cn-advanced-form-dialog__table-row--non-editable': !isPropertyEditable(key, resolvedValue(key, value)),
28
- [getPropertyValidationClass(key, value)]: validationDisplay === 'indicator',
29
- }"
30
- @click="handleRowClick(key, $event)">
31
- <td class="cn-advanced-form-dialog__table-col-constrained cn-advanced-form-dialog__prop-cell"
32
- :style="getPropCellStyle(key, value)">
33
- <div class="cn-advanced-form-dialog__prop-cell-content">
34
- <AlertCircle
35
- v-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'invalid'"
36
- class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--error"
37
- :size="16"
38
- :title="getPropertyErrorMessage(key, resolvedValue(key, value))" />
39
- <Alert
40
- v-else-if="validationDisplay === 'indicator' && isValueChanged(key) && getPropertyValidationState(key, resolvedValue(key, value)) !== 'invalid'"
41
- class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--edited"
42
- :size="16"
43
- :title="t('nextcloud-vue', 'This field has been changed')" />
44
- <Alert
45
- v-else-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'warning'"
46
- class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--warning"
47
- :size="16"
48
- :title="getPropertyWarningMessage(key, resolvedValue(key, value))" />
49
- <Plus
50
- v-else-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'new'"
51
- class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--new"
52
- :size="16"
53
- :title="getPropertyNewMessage(key)" />
54
- <LockOutline
55
- v-else-if="!isPropertyEditable(key, resolvedValue(key, value))"
56
- class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--lock"
57
- :size="16"
58
- :title="getEditabilityWarning(key, resolvedValue(key, value)) || ''" />
59
- <span :title="getPropertyTooltip(key)">{{ getPropertyDisplayName(key) }}</span>
60
- <span
61
- v-if="isRequired(key)"
62
- class="cn-advanced-form-dialog__required-indicator"
63
- :title="t('nextcloud-vue', 'Required')"
64
- aria-label="required">*</span>
65
- <span
66
- v-if="isImmutableHint(key)"
67
- class="cn-advanced-form-dialog__immutable-badge"
68
- :title="t('nextcloud-vue', 'This value can be set on creation but cannot be changed afterwards.')">
69
- {{ t('nextcloud-vue', 'Set once') }}
70
- </span>
71
- </div>
72
- </td>
73
- <td class="cn-advanced-form-dialog__table-col-expanded cn-advanced-form-dialog__value-cell">
74
- <slot
75
- name="value-cell"
76
- :property-key="key"
77
- :value="value"
78
- :resolved-value="resolvedValue(key, value)"
79
- :is-editing="selectedProperty === key"
80
- :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
81
- :display-name="getPropertyDisplayName(key)"
82
- :schema-prop="schema && schema.properties && schema.properties[key]"
83
- :editability-warning="getPropertyEditabilityWarning(key, resolvedValue(key, value))"
84
- :on-update="(v) => onPropertyValueUpdate(key, v)">
85
- <CnPropertyValueCell
86
- :ref="'cell-' + key"
2
+ <div>
3
+ <NcNoteCard
4
+ v-if="hasUnsavedChanges"
5
+ type="warning"
6
+ class="cn-advanced-form-dialog__unsaved-note">
7
+ {{ t('nextcloud-vue', 'You have unsaved changes. Save to apply them.') }}
8
+ </NcNoteCard>
9
+ <div class="cn-advanced-form-dialog__table-container">
10
+ <table class="cn-advanced-form-dialog__table">
11
+ <thead>
12
+ <tr class="cn-advanced-form-dialog__table-row">
13
+ <th class="cn-advanced-form-dialog__table-col-constrained">
14
+ {{ t('nextcloud-vue', 'Property') }}
15
+ </th>
16
+ <th class="cn-advanced-form-dialog__table-col-expanded">
17
+ {{ t('nextcloud-vue', 'Value') }}
18
+ </th>
19
+ <th
20
+ v-if="hasRowActionsSlot"
21
+ class="cn-advanced-form-dialog__table-col-actions">
22
+ <slot name="row-actions-header" />
23
+ </th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <tr
28
+ v-for="([key, value]) in objectProperties"
29
+ :key="key"
30
+ class="cn-advanced-form-dialog__table-row"
31
+ :class="{
32
+ 'cn-advanced-form-dialog__table-row--selected': selectedProperty === key,
33
+ 'cn-advanced-form-dialog__table-row--edited': isValueChanged(key),
34
+ 'cn-advanced-form-dialog__table-row--non-editable': !isPropertyEditable(key, resolvedValue(key, value)),
35
+ [getPropertyValidationClass(key, value)]: validationDisplay === 'indicator',
36
+ }"
37
+ @click="handleRowClick(key, $event)">
38
+ <td class="cn-advanced-form-dialog__table-col-constrained cn-advanced-form-dialog__prop-cell"
39
+ :style="getPropCellStyle(key, value)">
40
+ <div class="cn-advanced-form-dialog__prop-cell-content">
41
+ <AlertCircle
42
+ v-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'invalid'"
43
+ class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--error"
44
+ :size="16"
45
+ :title="getPropertyErrorMessage(key, resolvedValue(key, value))" />
46
+ <PencilOutline
47
+ v-else-if="validationDisplay === 'indicator' && isValueChanged(key) && getPropertyValidationState(key, resolvedValue(key, value)) !== 'invalid'"
48
+ v-tooltip="t('nextcloud-vue', 'This field has been changed — save to apply')"
49
+ class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--edited"
50
+ :size="16" />
51
+ <Alert
52
+ v-else-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'warning'"
53
+ class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--warning"
54
+ :size="16"
55
+ :title="getPropertyWarningMessage(key, resolvedValue(key, value))" />
56
+ <Plus
57
+ v-else-if="validationDisplay === 'indicator' && getPropertyValidationState(key, resolvedValue(key, value)) === 'new'"
58
+ class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--new"
59
+ :size="16"
60
+ :title="getPropertyNewMessage(key)" />
61
+ <LockOutline
62
+ v-else-if="!isPropertyEditable(key, resolvedValue(key, value))"
63
+ class="cn-advanced-form-dialog__validation-icon cn-advanced-form-dialog__validation-icon--lock"
64
+ :size="16"
65
+ :title="getEditabilityWarning(key, resolvedValue(key, value)) || ''" />
66
+ <span :title="getPropertyTooltip(key)">{{ getPropertyDisplayName(key) }}</span>
67
+ <span
68
+ v-if="isRequired(key)"
69
+ class="cn-advanced-form-dialog__required-indicator"
70
+ :title="t('nextcloud-vue', 'Required')"
71
+ aria-label="required">*</span>
72
+ <span
73
+ v-if="isImmutableHint(key)"
74
+ class="cn-advanced-form-dialog__immutable-badge"
75
+ :title="t('nextcloud-vue', 'This value can be set on creation but cannot be changed afterwards.')">
76
+ {{ t('nextcloud-vue', 'Set once') }}
77
+ </span>
78
+ </div>
79
+ </td>
80
+ <td class="cn-advanced-form-dialog__table-col-expanded cn-advanced-form-dialog__value-cell">
81
+ <slot
82
+ name="value-cell"
87
83
  :property-key="key"
88
- :schema="schema"
89
- :value="resolvedValue(key, value)"
90
- :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
84
+ :value="value"
85
+ :resolved-value="resolvedValue(key, value)"
91
86
  :is-editing="selectedProperty === key"
87
+ :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
92
88
  :display-name="getPropertyDisplayName(key)"
89
+ :schema-prop="schema && schema.properties && schema.properties[key]"
93
90
  :editability-warning="getPropertyEditabilityWarning(key, resolvedValue(key, value))"
94
- :widget="(propertyOverrides[key] && propertyOverrides[key].widget) || null"
95
- :select-options="(propertyOverrides[key] && propertyOverrides[key].selectOptions) || null"
96
- :select-multiple="propertyOverrides[key] ? propertyOverrides[key].selectMultiple !== false : true"
97
- :textarea-rows="(propertyOverrides[key] && propertyOverrides[key].textareaRows) || 4"
98
- @update:value="onPropertyValueUpdate(key, $event)" />
99
- </slot>
100
- </td>
101
- <td
102
- v-if="hasRowActionsSlot"
103
- class="cn-advanced-form-dialog__table-col-actions"
104
- @click.stop>
105
- <slot
106
- name="row-actions"
107
- :property-key="key"
108
- :value="value"
109
- :resolved-value="resolvedValue(key, value)"
110
- :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
111
- :is-schema-property="!!(schema && schema.properties && Object.prototype.hasOwnProperty.call(schema.properties, key))" />
112
- </td>
113
- </tr>
114
- </tbody>
115
- </table>
91
+ :on-update="(v) => onPropertyValueUpdate(key, v)">
92
+ <CnPropertyValueCell
93
+ :ref="'cell-' + key"
94
+ :property-key="key"
95
+ :schema="schema"
96
+ :value="resolvedValue(key, value)"
97
+ :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
98
+ :is-editing="selectedProperty === key"
99
+ :display-name="getPropertyDisplayName(key)"
100
+ :editability-warning="getPropertyEditabilityWarning(key, resolvedValue(key, value))"
101
+ :widget="(propertyOverrides[key] && propertyOverrides[key].widget) || null"
102
+ :select-options="(propertyOverrides[key] && propertyOverrides[key].selectOptions) || null"
103
+ :select-multiple="propertyOverrides[key] ? propertyOverrides[key].selectMultiple !== false : true"
104
+ :textarea-rows="(propertyOverrides[key] && propertyOverrides[key].textareaRows) || 4"
105
+ @update:value="onPropertyValueUpdate(key, $event)" />
106
+ </slot>
107
+ </td>
108
+ <td
109
+ v-if="hasRowActionsSlot"
110
+ class="cn-advanced-form-dialog__table-col-actions"
111
+ @click.stop>
112
+ <slot
113
+ name="row-actions"
114
+ :property-key="key"
115
+ :value="value"
116
+ :resolved-value="resolvedValue(key, value)"
117
+ :is-editable="isPropertyEditable(key, resolvedValue(key, value))"
118
+ :is-schema-property="!!(schema && schema.properties && Object.prototype.hasOwnProperty.call(schema.properties, key))" />
119
+ </td>
120
+ </tr>
121
+ </tbody>
122
+ </table>
123
+ </div>
116
124
  </div>
117
125
  </template>
118
126
 
119
127
  <script>
120
128
  import { translate as t } from '@nextcloud/l10n'
129
+ import { NcNoteCard } from '@nextcloud/vue'
121
130
  import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
122
131
  import Alert from 'vue-material-design-icons/Alert.vue'
132
+ import PencilOutline from 'vue-material-design-icons/PencilOutline.vue'
123
133
  import LockOutline from 'vue-material-design-icons/LockOutline.vue'
124
134
  import Plus from 'vue-material-design-icons/Plus.vue'
125
135
  import CnPropertyValueCell from './CnPropertyValueCell.vue'
@@ -128,8 +138,10 @@ export default {
128
138
  name: 'CnPropertiesTab',
129
139
 
130
140
  components: {
141
+ NcNoteCard,
131
142
  AlertCircle,
132
143
  Alert,
144
+ PencilOutline,
133
145
  LockOutline,
134
146
  Plus,
135
147
  CnPropertyValueCell,
@@ -166,9 +178,14 @@ export default {
166
178
  * Any other CSS color string is applied directly.
167
179
  */
168
180
  propCellColor: { type: String, default: null },
181
+ isNew: { type: Boolean, default: false },
169
182
  },
170
183
 
171
184
  computed: {
185
+ hasUnsavedChanges() {
186
+ if (this.isNew) return false
187
+ return Object.keys(this.formData).some(key => this.isValueChanged(key))
188
+ },
172
189
  propCellStyle() {
173
190
  if (this.propCellColor === null) return undefined
174
191
  if (this.propCellColor === 'none') return { boxShadow: 'none' }
@@ -255,7 +272,7 @@ export default {
255
272
  return { boxShadow: 'inset 3px 0 0 0 var(--color-error)' }
256
273
  }
257
274
  if (this.isValueChanged(key)) {
258
- return { boxShadow: 'inset 3px 0 0 0 var(--color-warning)' }
275
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-primary-element)' }
259
276
  }
260
277
  if (state === 'valid') {
261
278
  return { boxShadow: 'inset 3px 0 0 0 var(--color-success)' }
@@ -264,7 +281,7 @@ export default {
264
281
  return { boxShadow: 'inset 3px 0 0 0 var(--color-warning)' }
265
282
  }
266
283
  if (state === 'new') {
267
- return { boxShadow: 'inset 3px 0 0 0 var(--color-primary-element)' }
284
+ return { boxShadow: 'inset 3px 0 0 0 var(--color-new)' }
268
285
  }
269
286
  return this.propCellStyle
270
287
  },
@@ -590,10 +607,6 @@ export default {
590
607
  box-shadow: inset 3px 0 0 0 var(--color-primary);
591
608
  }
592
609
 
593
- .cn-advanced-form-dialog__table-row--edited {
594
- background-color: var(--color-warning-light);
595
- }
596
-
597
610
  .cn-advanced-form-dialog__table-row--non-editable {
598
611
  background-color: var(--color-background-dark);
599
612
  cursor: not-allowed;
@@ -661,7 +674,7 @@ export default {
661
674
  }
662
675
 
663
676
  .cn-advanced-form-dialog__validation-icon--edited {
664
- color: var(--color-warning);
677
+ color: var(--color-primary-element);
665
678
  }
666
679
 
667
680
  .cn-advanced-form-dialog__validation-icon--warning {
@@ -673,7 +686,7 @@ export default {
673
686
  }
674
687
 
675
688
  .cn-advanced-form-dialog__validation-icon--new {
676
- color: var(--color-primary-element);
689
+ color: var(--color-new);
677
690
  }
678
691
 
679
692
  .cn-advanced-form-dialog__required-indicator {
@@ -695,4 +708,8 @@ export default {
695
708
  cursor: help;
696
709
  white-space: nowrap;
697
710
  }
711
+
712
+ .cn-advanced-form-dialog__unsaved-note {
713
+ margin-bottom: calc(3 * var(--default-grid-baseline));
714
+ }
698
715
  </style>
@@ -551,6 +551,15 @@ export default {
551
551
  datetimeValue() {
552
552
  const v = this.value
553
553
  if (!v) return null
554
+ // Date-only strings (YYYY-MM-DD) are parsed as UTC midnight by the spec,
555
+ // which shifts to the previous day in positive-UTC-offset timezones when
556
+ // fed to a picker that renders in local time. Parse them as local midnight.
557
+ if (this.schemaProp?.format === 'date'
558
+ && typeof v === 'string'
559
+ && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
560
+ const [year, month, day] = v.split('-').map(Number)
561
+ return new Date(year, month - 1, day)
562
+ }
554
563
  const d = new Date(v)
555
564
  return Number.isNaN(d.getTime()) ? null : d
556
565
  },
@@ -594,6 +603,11 @@ export default {
594
603
  const v = this.value
595
604
  if (!v) return ''
596
605
  const fmt = this.schemaProp?.format
606
+ // Same local-midnight parse as datetimeValue to avoid UTC-shift in display.
607
+ if (fmt === 'date' && typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
608
+ const [year, month, day] = v.split('-').map(Number)
609
+ return new Date(year, month - 1, day).toLocaleDateString()
610
+ }
597
611
  const d = new Date(v)
598
612
  if (Number.isNaN(d.getTime())) return String(v)
599
613
  if (fmt === 'date') return d.toLocaleDateString()
@@ -663,7 +677,12 @@ export default {
663
677
  }
664
678
  const fmt = this.schemaProp?.format
665
679
  if (fmt === 'date') {
666
- this.$emit('update:value', date.toISOString().slice(0, 10))
680
+ // Use local-time accessors — toISOString() converts to UTC first,
681
+ // which shifts midnight local time to the previous day in UTC+n zones.
682
+ const year = date.getFullYear()
683
+ const month = String(date.getMonth() + 1).padStart(2, '0')
684
+ const day = String(date.getDate()).padStart(2, '0')
685
+ this.$emit('update:value', `${year}-${month}-${day}`)
667
686
  return
668
687
  }
669
688
  if (fmt === 'time') {
@@ -83,7 +83,13 @@
83
83
  :borderless="item.showTitle === false"
84
84
  :flush="item.flush === true"
85
85
  :buttons="getWidgetButtons(item)"
86
- :style-config="item.styleConfig || {}">
86
+ :style-config="item.styleConfig || {}"
87
+ :title-icon-position="getWidgetTitleIconPosition(item)"
88
+ :title-icon-color="getWidgetTitleIconColor(item)">
89
+ <!-- Per-widget title icon (e.g. #widget-my-work-title-icon) -->
90
+ <template v-if="$slots['widget-' + item.widgetId + '-title-icon']" #title-icon>
91
+ <slot :name="'widget-' + item.widgetId + '-title-icon'" :item="item" :widget="getWidgetDef(item.widgetId)" />
92
+ </template>
87
93
  <!-- Per-widget header actions (e.g. #widget-my-work-actions) -->
88
94
  <template v-if="$slots['widget-' + item.widgetId + '-actions']" #actions>
89
95
  <slot :name="'widget-' + item.widgetId + '-actions'" :item="item" :widget="getWidgetDef(item.widgetId)" />
@@ -320,6 +326,16 @@ export default {
320
326
  return def?.buttons || []
321
327
  },
322
328
 
329
+ getWidgetTitleIconPosition(item) {
330
+ const def = this.getWidgetDef(item.widgetId)
331
+ return def?.titleIconPosition || 'right'
332
+ },
333
+
334
+ getWidgetTitleIconColor(item) {
335
+ const def = this.getWidgetDef(item.widgetId)
336
+ return def?.titleIconColor || null
337
+ },
338
+
323
339
  isTile(item) {
324
340
  const def = this.getWidgetDef(item.widgetId)
325
341
  return def?.type === 'tile'
@@ -128,7 +128,7 @@ export default {
128
128
  computed: {
129
129
  itemName() {
130
130
  if (this.nameFormatter) return this.nameFormatter(this.item)
131
- return this.item[this.nameField] || this.item.name || this.item.title || this.item.id
131
+ return this.item[this.nameField] || this.item.name || this.item.naam || this.item.title || this.item.id
132
132
  },
133
133
  resolvedWarningText() {
134
134
  return this.warningText.replace('{name}', this.itemName)
@@ -15,6 +15,12 @@
15
15
  :style="wrapperStyles">
16
16
  <!-- Header -->
17
17
  <div v-if="showTitle" class="cn-widget-wrapper__header">
18
+ <!-- Title icon — left: rendered before the title group -->
19
+ <div v-if="$slots['title-icon'] && titleIconPosition === 'left'"
20
+ class="cn-widget-wrapper__title-icon"
21
+ :style="titleIconColor ? { color: titleIconColor } : {}">
22
+ <slot name="title-icon" />
23
+ </div>
18
24
  <div class="cn-widget-wrapper__header-left">
19
25
  <img
20
26
  v-if="iconUrl"
@@ -32,6 +38,12 @@
32
38
  <div class="cn-widget-wrapper__actions">
33
39
  <slot name="actions" />
34
40
  </div>
41
+ <!-- Title icon — right: rendered after actions, far right -->
42
+ <div v-if="$slots['title-icon'] && titleIconPosition === 'right'"
43
+ class="cn-widget-wrapper__title-icon"
44
+ :style="titleIconColor ? { color: titleIconColor } : {}">
45
+ <slot name="title-icon" />
46
+ </div>
35
47
  </div>
36
48
 
37
49
  <!-- Content -->
@@ -114,6 +126,20 @@ export default {
114
126
  type: String,
115
127
  default: null,
116
128
  },
129
+ /**
130
+ * Position of the title-icon slot in the header.
131
+ * 'left' places it before the title; 'right' places it after the actions.
132
+ */
133
+ titleIconPosition: {
134
+ type: String,
135
+ default: 'right',
136
+ validator: (v) => ['left', 'right'].includes(v),
137
+ },
138
+ /** CSS color value applied to the title-icon slot container */
139
+ titleIconColor: {
140
+ type: String,
141
+ default: null,
142
+ },
117
143
  /** Footer action buttons: [{ text, link }] */
118
144
  buttons: {
119
145
  type: Array,
@@ -227,6 +253,12 @@ export default {
227
253
  flex-shrink: 0;
228
254
  }
229
255
 
256
+ .cn-widget-wrapper__title-icon {
257
+ display: flex;
258
+ align-items: center;
259
+ flex-shrink: 0;
260
+ }
261
+
230
262
  .cn-widget-wrapper__footer {
231
263
  display: flex;
232
264
  justify-content: flex-end;
package/src/css/index.css CHANGED
@@ -1,4 +1,5 @@
1
1
  /* @conduction/nextcloud-vue — Main CSS entry point */
2
+ @import './main.css';
2
3
  @import './table.css';
3
4
  @import './card.css';
4
5
  @import './pagination.css';
@@ -0,0 +1,3 @@
1
+ :root {
2
+ --color-new: var(--color-text-maxcontrast-default);
3
+ }
@@ -9,7 +9,7 @@ import { parseResponseError, networkError } from '../../utils/errors.js'
9
9
  * upload (multipart), publish, unpublish, and delete.
10
10
  *
11
11
  * State: files, filesLoading, filesError, tags, tagsLoading, tagsError
12
- * Actions: fetchFiles, uploadFiles, publishFile, unpublishFile, deleteFile, clearFiles, fetchTags
12
+ * Actions: fetchFiles, uploadFiles, publishFile, unpublishFile, deleteFile, batchFiles, clearFiles, fetchTags
13
13
  * Getters: getFiles, isFilesLoading, getFilesError, getTags, isTagsLoading, getTagsError
14
14
  *
15
15
  * @param {object} [options={}] Plugin options
@@ -245,6 +245,58 @@ export function filesPlugin(options = {}) {
245
245
  this.filesLoading = false
246
246
  }
247
247
  },
248
+
249
+ /**
250
+ * Apply a batch action across multiple files in ONE request.
251
+ *
252
+ * Replaces the N-sequential-call pattern (loop calling
253
+ * publishFile/unpublishFile/deleteFile per id) with a single POST
254
+ * to /files/batch. The backend returns 200 when every operation
255
+ * succeeds, or 207 (multi-status) when some fail; the per-file
256
+ * outcomes live in `data.results` and the aggregate counts in
257
+ * `data.summary` (`{ succeeded, failed, total }`).
258
+ *
259
+ * @param {string} type The registered object type slug
260
+ * @param {string} objectId The parent object ID
261
+ * @param {('publish'|'depublish'|'delete'|'label')} action The batch action to apply
262
+ * @param {(string|number)[]} fileIds File IDs to act on (max 100, validated server-side)
263
+ * @param {object} [params={}] Action-specific parameters (e.g. labels for the 'label' action)
264
+ * @return {Promise<object|null>} Response body `{ results, summary }`, or null on transport error
265
+ */
266
+ async batchFiles(type, objectId, action, fileIds, params = {}) {
267
+ this.filesLoading = true
268
+ this.filesError = null
269
+
270
+ try {
271
+ const url = this._buildUrl(type, objectId) + '/files/batch'
272
+
273
+ const response = await fetch(url, {
274
+ method: 'POST',
275
+ headers: buildHeaders(),
276
+ body: JSON.stringify({
277
+ action,
278
+ fileIds,
279
+ ...params,
280
+ }),
281
+ })
282
+
283
+ // 200 = all succeeded, 207 = partial success — both are
284
+ // valid responses; the caller inspects data.summary.
285
+ if (!response.ok && response.status !== 207) {
286
+ this.filesError = await parseResponseError(response, 'files')
287
+ return null
288
+ }
289
+
290
+ const data = await response.json()
291
+ await this.fetchFiles(type, objectId)
292
+ return data
293
+ } catch (error) {
294
+ this.filesError = networkError(error)
295
+ return null
296
+ } finally {
297
+ this.filesLoading = false
298
+ }
299
+ },
248
300
  },
249
301
  }
250
302
  }