@conduction/nextcloud-vue 0.1.0-beta.6 → 0.1.0-beta.8

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 (82) hide show
  1. package/dist/nextcloud-vue.cjs.js +13575 -2374
  2. package/dist/nextcloud-vue.cjs.js.map +1 -1
  3. package/dist/nextcloud-vue.css +1238 -270
  4. package/dist/nextcloud-vue.esm.js +13517 -2336
  5. package/dist/nextcloud-vue.esm.js.map +1 -1
  6. package/package.json +11 -7
  7. package/src/components/CnActionsBar/CnActionsBar.vue +20 -2
  8. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +1 -11
  9. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +5 -1
  10. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +1 -1
  11. package/src/components/CnCard/CnCard.vue +415 -0
  12. package/src/components/CnCard/index.js +1 -0
  13. package/src/components/CnCardGrid/CnCardGrid.vue +20 -20
  14. package/src/components/CnChartWidget/CnChartWidget.vue +3 -1
  15. package/src/components/CnCopyDialog/CnCopyDialog.vue +7 -1
  16. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +4 -0
  17. package/src/components/CnDashboardPage/CnDashboardPage.vue +2 -0
  18. package/src/components/CnDataTable/CnDataTable.vue +6 -2
  19. package/src/components/CnDeleteDialog/CnDeleteDialog.vue +7 -1
  20. package/src/components/CnDetailCard/CnDetailCard.vue +12 -1
  21. package/src/components/CnDetailGrid/CnDetailGrid.vue +254 -0
  22. package/src/components/CnDetailGrid/index.js +1 -0
  23. package/src/components/CnDetailPage/CnDetailPage.vue +157 -11
  24. package/src/components/CnFacetSidebar/CnFacetSidebar.vue +3 -1
  25. package/src/components/CnFormDialog/CnFormDialog.vue +934 -920
  26. package/src/components/CnIcon/CnIcon.vue +1 -1
  27. package/src/components/CnIndexPage/CnIndexPage.vue +63 -9
  28. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +37 -9
  29. package/src/components/CnInfoWidget/CnInfoWidget.vue +219 -0
  30. package/src/components/CnInfoWidget/index.js +1 -0
  31. package/src/components/CnJsonViewer/CnJsonViewer.vue +283 -0
  32. package/src/components/CnJsonViewer/index.js +1 -0
  33. package/src/components/CnKpiGrid/CnKpiGrid.vue +5 -1
  34. package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +7 -1
  35. package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +7 -1
  36. package/src/components/CnMassExportDialog/CnMassExportDialog.vue +1 -1
  37. package/src/components/CnMassImportDialog/CnMassImportDialog.vue +1 -1
  38. package/src/components/CnObjectCard/CnObjectCard.vue +1 -1
  39. package/src/components/CnObjectSidebar/CnAuditTrailTab.vue +368 -0
  40. package/src/components/CnObjectSidebar/CnFilesTab.vue +286 -0
  41. package/src/components/CnObjectSidebar/CnNotesTab.vue +249 -0
  42. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +45 -668
  43. package/src/components/CnObjectSidebar/CnTagsTab.vue +258 -0
  44. package/src/components/CnObjectSidebar/CnTasksTab.vue +482 -0
  45. package/src/components/CnObjectSidebar/index.js +5 -0
  46. package/src/components/CnProgressBar/CnProgressBar.vue +262 -0
  47. package/src/components/CnProgressBar/index.js +1 -0
  48. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +1 -1
  49. package/src/components/CnStatsBlock/CnStatsBlock.vue +27 -11
  50. package/src/components/CnStatsPanel/CnStatsPanel.vue +320 -0
  51. package/src/components/CnStatsPanel/index.js +1 -0
  52. package/src/components/CnStatusBadge/CnStatusBadge.vue +15 -2
  53. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +5 -1
  54. package/src/components/CnTableWidget/CnTableWidget.vue +332 -0
  55. package/src/components/CnTableWidget/index.js +1 -0
  56. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +36 -1
  57. package/src/components/index.js +11 -0
  58. package/src/composables/useDashboardView.js +58 -12
  59. package/src/composables/useDetailView.js +3 -2
  60. package/src/composables/useListView.js +7 -6
  61. package/src/composables/useSubResource.js +3 -3
  62. package/src/css/badge.css +32 -0
  63. package/src/css/card.css +1 -0
  64. package/src/css/detail-page.css +74 -7
  65. package/src/index.js +16 -0
  66. package/src/mixins/gridLayout.js +118 -0
  67. package/src/store/createCrudStore.js +360 -0
  68. package/src/store/createSubResourcePlugin.js +5 -15
  69. package/src/store/index.js +1 -0
  70. package/src/store/plugins/auditTrails.js +346 -6
  71. package/src/store/plugins/lifecycle.js +4 -4
  72. package/src/store/plugins/registerMapping.js +18 -8
  73. package/src/store/plugins/relations.js +1 -1
  74. package/src/store/plugins/search.js +21 -8
  75. package/src/store/useObjectStore.js +30 -36
  76. package/src/utils/getTheme.js +9 -0
  77. package/src/utils/headers.js +13 -3
  78. package/src/utils/index.js +1 -0
  79. package/src/utils/schema.js +3 -3
  80. package/src/utils/widgetVisibility.js +162 -0
  81. package/src/components/CnObjectCard/eslint-setup.md +0 -235
  82. package/src/components/CnObjectCard/package.json-or.json +0 -132
@@ -0,0 +1,368 @@
1
+ <template>
2
+ <div class="cn-sidebar-tab">
3
+ <NcLoadingIcon v-if="loading" />
4
+ <template v-else-if="entries.length > 0">
5
+ <!-- Filters -->
6
+ <div class="cn-audit-filters">
7
+ <NcSelect
8
+ v-model="filterAction"
9
+ :options="actionOptions"
10
+ :placeholder="actionFilterLabel"
11
+ :multiple="true"
12
+ :close-on-select="false"
13
+ class="cn-audit-filters__select" />
14
+ <NcSelect
15
+ v-model="filterUser"
16
+ :options="userOptions"
17
+ :placeholder="userFilterLabel"
18
+ :multiple="true"
19
+ :close-on-select="false"
20
+ class="cn-audit-filters__select" />
21
+ <NcDateTimePickerNative
22
+ id="audit-date-from"
23
+ v-model="filterDateFrom"
24
+ :label="fromLabel"
25
+ type="date"
26
+ class="cn-audit-filters__date" />
27
+ <NcDateTimePickerNative
28
+ id="audit-date-to"
29
+ v-model="filterDateTo"
30
+ :label="toLabel"
31
+ type="date"
32
+ class="cn-audit-filters__date" />
33
+ </div>
34
+
35
+ <div v-if="entries.length === 0 && !loading" class="cn-sidebar-tab__empty">
36
+ {{ noMatchLabel }}
37
+ </div>
38
+ <div v-else class="cn-sidebar-tab__list">
39
+ <div v-for="entry in entries" :key="entry.id" class="cn-audit-entry">
40
+ <NcListItem
41
+ :name="formatDate(entry.created)"
42
+ :bold="false"
43
+ :details="entry.action"
44
+ :counter-number="changedCount(entry)"
45
+ @click="toggleExpand(entry.id)">
46
+ <template #icon>
47
+ <History :size="32" />
48
+ </template>
49
+ <template #subname>
50
+ {{ entry.userName || entry.user || 'System' }}
51
+ </template>
52
+ </NcListItem>
53
+ <!-- Expandable details -->
54
+ <div v-if="expandedId === entry.id" class="cn-audit-details">
55
+ <div class="cn-audit-details__row">
56
+ <span class="cn-audit-details__label">Action</span>
57
+ <span>{{ entry.action }}</span>
58
+ </div>
59
+ <div class="cn-audit-details__row">
60
+ <span class="cn-audit-details__label">User</span>
61
+ <span>{{ entry.userName || entry.user || 'System' }}</span>
62
+ </div>
63
+ <div class="cn-audit-details__row">
64
+ <span class="cn-audit-details__label">Date</span>
65
+ <span>{{ formatDate(entry.created) }}</span>
66
+ </div>
67
+ <div v-if="entry.ipAddress" class="cn-audit-details__row">
68
+ <span class="cn-audit-details__label">IP</span>
69
+ <span>{{ entry.ipAddress }}</span>
70
+ </div>
71
+ <div v-if="entry.session" class="cn-audit-details__row">
72
+ <span class="cn-audit-details__label">Session</span>
73
+ <span class="cn-audit-details__mono">{{ entry.session }}</span>
74
+ </div>
75
+ <!-- Changed fields -->
76
+ <div v-if="entry.changed && Object.keys(entry.changed).length > 0" class="cn-audit-details__changes">
77
+ <span class="cn-audit-details__label">Changes</span>
78
+ <div
79
+ v-for="(change, field) in entry.changed"
80
+ :key="field"
81
+ class="cn-audit-details__change">
82
+ <span class="cn-audit-details__field">{{ field }}</span>
83
+ <div v-if="isSimpleValue(change)" class="cn-audit-details__values">
84
+ <span v-if="change.old !== null && change.old !== undefined" class="cn-audit-details__old">{{ formatValue(change.old) }}</span>
85
+ <span v-if="change.old !== null && change.new !== null" class="cn-audit-details__arrow">→</span>
86
+ <span v-if="change.new !== null && change.new !== undefined" class="cn-audit-details__new">{{ formatValue(change.new) }}</span>
87
+ <span v-if="change.old === null && change.new === null" class="cn-audit-details__null">null</span>
88
+ </div>
89
+ <div v-else class="cn-audit-details__values">
90
+ <span class="cn-audit-details__mono">{{ formatValue(change) }}</span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ <!-- Load more -->
97
+ <NcButton
98
+ v-if="hasMore"
99
+ type="tertiary"
100
+ :wide="true"
101
+ :disabled="loadingMore"
102
+ class="cn-sidebar-tab__load-more"
103
+ @click="loadMore">
104
+ <template v-if="loadingMore" #icon>
105
+ <NcLoadingIcon :size="20" />
106
+ </template>
107
+ {{ loadingMore ? '' : loadMoreLabel }}
108
+ </NcButton>
109
+ </div>
110
+ </template>
111
+ <div v-else class="cn-sidebar-tab__empty">
112
+ {{ noAuditTrailLabel }}
113
+ </div>
114
+ </div>
115
+ </template>
116
+
117
+ <script>
118
+ import { NcButton, NcListItem, NcLoadingIcon, NcSelect, NcDateTimePickerNative } from '@nextcloud/vue'
119
+ import History from 'vue-material-design-icons/History.vue'
120
+ import { buildHeaders } from '../../utils/index.js'
121
+
122
+ export default {
123
+ name: 'CnAuditTrailTab',
124
+
125
+ components: { NcButton, NcListItem, NcLoadingIcon, NcSelect, NcDateTimePickerNative, History },
126
+
127
+ props: {
128
+ objectId: { type: String, required: true },
129
+ register: { type: String, default: '' },
130
+ schema: { type: String, default: '' },
131
+ apiBase: { type: String, default: '/apps/openregister/api' },
132
+ noAuditTrailLabel: { type: String, default: 'No audit trail entries' },
133
+ noMatchLabel: { type: String, default: 'No matching entries' },
134
+ actionFilterLabel: { type: String, default: 'Action' },
135
+ userFilterLabel: { type: String, default: 'User' },
136
+ fromLabel: { type: String, default: 'From' },
137
+ toLabel: { type: String, default: 'To' },
138
+ loadMoreLabel: { type: String, default: 'Load more' },
139
+ },
140
+
141
+ data() {
142
+ return {
143
+ entries: [],
144
+ loading: false,
145
+ loadingMore: false,
146
+ expandedId: null,
147
+ filterAction: [],
148
+ filterUser: [],
149
+ filterDateFrom: null,
150
+ filterDateTo: null,
151
+ page: 1,
152
+ total: 0,
153
+ limit: 20,
154
+ actionOptions: ['create', 'read', 'update', 'delete'],
155
+ userOptions: [],
156
+ }
157
+ },
158
+
159
+ computed: {
160
+ hasMore() {
161
+ return this.entries.length < this.total
162
+ },
163
+ },
164
+
165
+ watch: {
166
+ objectId: {
167
+ immediate: true,
168
+ handler(id) { if (id) this.fetchAuditTrails() },
169
+ },
170
+ filterAction() { this.resetAndFetch() },
171
+ filterUser() { this.resetAndFetch() },
172
+ filterDateFrom() { this.resetAndFetch() },
173
+ filterDateTo() { this.resetAndFetch() },
174
+ },
175
+
176
+ methods: {
177
+ resetAndFetch() {
178
+ this.page = 1
179
+ this.entries = []
180
+ this.fetchAuditTrails()
181
+ },
182
+
183
+ buildQueryParams() {
184
+ const params = new URLSearchParams()
185
+ params.set('limit', this.limit)
186
+ params.set('_page', this.page)
187
+ params.set('_sort[created]', 'DESC')
188
+ if (this.filterAction?.length) params.set('action', this.filterAction.join(','))
189
+ if (this.filterUser?.length) params.set('user_name', this.filterUser.join(','))
190
+ if (this.filterDateFrom) {
191
+ params.set('_dateFrom', new Date(this.filterDateFrom).toISOString().split('T')[0])
192
+ }
193
+ if (this.filterDateTo) {
194
+ params.set('_dateTo', new Date(this.filterDateTo).toISOString().split('T')[0])
195
+ }
196
+ return params.toString()
197
+ },
198
+
199
+ async fetchAuditTrails() {
200
+ if (!this.register || !this.schema) return
201
+ this.loading = this.page === 1
202
+ this.loadingMore = this.page > 1
203
+ try {
204
+ const query = this.buildQueryParams()
205
+ const response = await fetch(
206
+ `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trails?${query}`,
207
+ { headers: buildHeaders() },
208
+ )
209
+ if (response.ok) {
210
+ const data = await response.json()
211
+ const results = data.results || data || []
212
+ if (this.page === 1) {
213
+ this.entries = results
214
+ } else {
215
+ this.entries = [...this.entries, ...results]
216
+ }
217
+ this.total = data.total || this.entries.length
218
+ // Build user options from all seen entries
219
+ const users = new Set(this.entries.map(e => e.userName || e.user).filter(Boolean))
220
+ this.userOptions = [...users].sort()
221
+ }
222
+ } catch (err) {
223
+ console.error('CnAuditTrailTab: Failed to fetch audit trails', err)
224
+ } finally {
225
+ this.loading = false
226
+ this.loadingMore = false
227
+ }
228
+ },
229
+
230
+ loadMore() {
231
+ this.page++
232
+ this.fetchAuditTrails()
233
+ },
234
+
235
+ toggleExpand(id) {
236
+ this.expandedId = this.expandedId === id ? null : id
237
+ },
238
+
239
+ changedCount(entry) {
240
+ if (!entry.changed || typeof entry.changed !== 'object') return 0
241
+ return Object.keys(entry.changed).length
242
+ },
243
+
244
+ isSimpleValue(change) {
245
+ return change && typeof change === 'object' && ('old' in change || 'new' in change)
246
+ },
247
+
248
+ formatValue(val) {
249
+ if (val === null || val === undefined) return 'null'
250
+ if (typeof val === 'object') return JSON.stringify(val)
251
+ return String(val)
252
+ },
253
+
254
+ formatDate(dateStr) {
255
+ if (!dateStr) return ''
256
+ try {
257
+ return new Date(dateStr).toLocaleString(undefined, {
258
+ year: 'numeric',
259
+ month: 'short',
260
+ day: 'numeric',
261
+ hour: '2-digit',
262
+ minute: '2-digit',
263
+ })
264
+ } catch { return dateStr }
265
+ },
266
+ },
267
+ }
268
+ </script>
269
+
270
+ <style scoped>
271
+ .cn-sidebar-tab { padding: 12px; }
272
+
273
+ .cn-sidebar-tab__empty {
274
+ text-align: center;
275
+ padding: 24px 12px;
276
+ color: var(--color-text-maxcontrast);
277
+ font-size: 13px;
278
+ }
279
+
280
+ .cn-sidebar-tab__list { display: flex; flex-direction: column; gap: 2px; }
281
+
282
+ .cn-audit-filters {
283
+ display: grid;
284
+ grid-template-columns: 1fr 1fr;
285
+ gap: 8px;
286
+ margin-bottom: 12px;
287
+ }
288
+
289
+ .cn-audit-filters__select { min-width: 0; }
290
+ .cn-audit-filters__date { min-width: 0; }
291
+
292
+ .cn-sidebar-tab__load-more { margin-top: 8px; }
293
+
294
+ .cn-audit-entry { cursor: pointer; }
295
+
296
+ .cn-audit-details {
297
+ padding: 8px 12px 12px 52px;
298
+ font-size: 12px;
299
+ border-bottom: 1px solid var(--color-border);
300
+ animation: cn-slide-down 0.15s ease;
301
+ }
302
+
303
+ @keyframes cn-slide-down {
304
+ from { opacity: 0; max-height: 0; }
305
+ to { opacity: 1; max-height: 600px; }
306
+ }
307
+
308
+ .cn-audit-details__row {
309
+ display: flex;
310
+ gap: 8px;
311
+ padding: 2px 0;
312
+ }
313
+
314
+ .cn-audit-details__label {
315
+ color: var(--color-text-maxcontrast);
316
+ min-width: 56px;
317
+ font-weight: 500;
318
+ }
319
+
320
+ .cn-audit-details__mono {
321
+ font-family: monospace;
322
+ font-size: 11px;
323
+ word-break: break-all;
324
+ }
325
+
326
+ .cn-audit-details__changes {
327
+ margin-top: 8px;
328
+ padding-top: 8px;
329
+ border-top: 1px solid var(--color-border);
330
+ }
331
+
332
+ .cn-audit-details__change {
333
+ padding: 4px 0 4px 8px;
334
+ border-left: 2px solid var(--color-border);
335
+ margin: 4px 0;
336
+ }
337
+
338
+ .cn-audit-details__field {
339
+ font-weight: 500;
340
+ display: block;
341
+ margin-bottom: 2px;
342
+ }
343
+
344
+ .cn-audit-details__values {
345
+ display: flex;
346
+ gap: 6px;
347
+ align-items: baseline;
348
+ flex-wrap: wrap;
349
+ }
350
+
351
+ .cn-audit-details__old {
352
+ color: var(--color-error, #e53935);
353
+ text-decoration: line-through;
354
+ }
355
+
356
+ .cn-audit-details__arrow {
357
+ color: var(--color-text-maxcontrast);
358
+ }
359
+
360
+ .cn-audit-details__new {
361
+ color: var(--color-success, #43a047);
362
+ }
363
+
364
+ .cn-audit-details__null {
365
+ color: var(--color-text-maxcontrast);
366
+ font-style: italic;
367
+ }
368
+ </style>
@@ -0,0 +1,286 @@
1
+ <template>
2
+ <div class="cn-sidebar-tab">
3
+ <!-- Upload error -->
4
+ <div v-if="uploadError" class="cn-sidebar-tab__upload-error">
5
+ {{ uploadError }}
6
+ </div>
7
+
8
+ <!-- File drop zone -->
9
+ <div
10
+ class="cn-sidebar-tab__dropzone"
11
+ :class="{ 'cn-sidebar-tab__dropzone--active': isDragOver }"
12
+ @click="triggerFileInput"
13
+ @dragover.prevent="onDragOver"
14
+ @dragleave.prevent="onDragLeave"
15
+ @drop.prevent="onDrop">
16
+ <input
17
+ ref="fileInput"
18
+ type="file"
19
+ multiple
20
+ class="cn-sidebar-tab__file-input"
21
+ @change="onFileUpload">
22
+ <Upload :size="24" class="cn-sidebar-tab__dropzone-icon" />
23
+ <span class="cn-sidebar-tab__dropzone-text">{{ dropZoneLabel }}</span>
24
+ </div>
25
+
26
+ <!-- File list -->
27
+ <NcLoadingIcon v-if="loading" />
28
+ <div v-else-if="files.length === 0" class="cn-sidebar-tab__empty">
29
+ {{ noFilesLabel }}
30
+ </div>
31
+ <div v-else class="cn-sidebar-tab__list">
32
+ <NcListItem
33
+ v-for="file in files"
34
+ :key="file.id"
35
+ :name="file.name || file.title"
36
+ :bold="false"
37
+ :force-display-actions="true">
38
+ <template #icon>
39
+ <FileOutline :size="32" />
40
+ </template>
41
+ <template #subname>
42
+ {{ formatFileSize(file.size) }}
43
+ </template>
44
+ <template #actions>
45
+ <NcActionButton @click="openFile(file)">
46
+ <template #icon>
47
+ <OpenInNew :size="20" />
48
+ </template>
49
+ {{ openLabel }}
50
+ </NcActionButton>
51
+ <NcActionButton @click="deleteFile(file)">
52
+ <template #icon>
53
+ <Delete :size="20" />
54
+ </template>
55
+ {{ deleteLabel }}
56
+ </NcActionButton>
57
+ </template>
58
+ </NcListItem>
59
+ </div>
60
+ <NcButton
61
+ v-if="files.length < total"
62
+ type="tertiary"
63
+ :wide="true"
64
+ :disabled="loadingMore"
65
+ class="cn-sidebar-tab__load-more"
66
+ @click="loadMore">
67
+ <template v-if="loadingMore" #icon>
68
+ <NcLoadingIcon :size="20" />
69
+ </template>
70
+ {{ loadingMore ? '' : loadMoreLabel }}
71
+ </NcButton>
72
+ </div>
73
+ </template>
74
+
75
+ <script>
76
+ import { NcButton, NcListItem, NcActionButton, NcLoadingIcon } from '@nextcloud/vue'
77
+ import Upload from 'vue-material-design-icons/Upload.vue'
78
+ import FileOutline from 'vue-material-design-icons/FileOutline.vue'
79
+ import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
80
+ import Delete from 'vue-material-design-icons/Delete.vue'
81
+ import { buildHeaders } from '../../utils/index.js'
82
+
83
+ export default {
84
+ name: 'CnFilesTab',
85
+
86
+ components: { NcButton, NcListItem, NcActionButton, NcLoadingIcon, Upload, FileOutline, OpenInNew, Delete },
87
+
88
+ props: {
89
+ objectId: { type: String, required: true },
90
+ register: { type: String, default: '' },
91
+ schema: { type: String, default: '' },
92
+ apiBase: { type: String, default: '/apps/openregister/api' },
93
+ dropZoneLabel: { type: String, default: 'Drop files here or click to browse' },
94
+ noFilesLabel: { type: String, default: 'No files attached' },
95
+ openLabel: { type: String, default: 'Open' },
96
+ deleteLabel: { type: String, default: 'Delete' },
97
+ loadMoreLabel: { type: String, default: 'Load more' },
98
+ },
99
+
100
+ data() {
101
+ return {
102
+ files: [],
103
+ loading: false,
104
+ loadingMore: false,
105
+ isDragOver: false,
106
+ uploadError: '',
107
+ page: 1,
108
+ total: 0,
109
+ limit: 20,
110
+ }
111
+ },
112
+
113
+ watch: {
114
+ objectId: {
115
+ immediate: true,
116
+ handler(id) { if (id) this.fetchFiles() },
117
+ },
118
+ },
119
+
120
+ methods: {
121
+ async fetchFiles(append = false) {
122
+ if (!this.register || !this.schema) return
123
+ if (append) { this.loadingMore = true } else { this.loading = true }
124
+ try {
125
+ const params = new URLSearchParams({ limit: this.limit, _page: this.page })
126
+ const response = await fetch(
127
+ `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files?${params}`,
128
+ { headers: buildHeaders() },
129
+ )
130
+ if (response.ok) {
131
+ const data = await response.json()
132
+ const results = data.results || data || []
133
+ this.files = append ? [...this.files, ...results] : results
134
+ this.total = data.total || this.files.length
135
+ }
136
+ } catch (err) {
137
+ console.error('CnFilesTab: Failed to fetch files', err)
138
+ } finally {
139
+ this.loading = false
140
+ this.loadingMore = false
141
+ }
142
+ },
143
+
144
+ loadMore() {
145
+ this.page++
146
+ this.fetchFiles(true)
147
+ },
148
+
149
+ triggerFileInput() {
150
+ this.$refs.fileInput?.click()
151
+ },
152
+
153
+ onDragOver() { this.isDragOver = true },
154
+ onDragLeave() { this.isDragOver = false },
155
+
156
+ onDrop(event) {
157
+ this.isDragOver = false
158
+ const droppedFiles = event.dataTransfer?.files
159
+ if (droppedFiles?.length) this.uploadFiles(droppedFiles)
160
+ },
161
+
162
+ async onFileUpload(event) {
163
+ const inputFiles = event.target.files
164
+ if (!inputFiles?.length) return
165
+ await this.uploadFiles(inputFiles)
166
+ if (this.$refs.fileInput) this.$refs.fileInput.value = ''
167
+ },
168
+
169
+ async uploadFiles(fileList) {
170
+ if (!fileList?.length || !this.register || !this.schema) return
171
+ this.uploadError = ''
172
+ const formData = new FormData()
173
+ for (const file of fileList) {
174
+ formData.append('files[]', file)
175
+ }
176
+
177
+ this.loading = true
178
+ try {
179
+ const response = await fetch(
180
+ `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/filesMultipart`,
181
+ {
182
+ method: 'POST',
183
+ headers: { requesttoken: OC?.requestToken || '', 'OCS-APIREQUEST': 'true' },
184
+ body: formData,
185
+ },
186
+ )
187
+ if (!response.ok) {
188
+ const data = await response.json().catch(() => ({}))
189
+ this.uploadError = data.error || `Upload failed (${response.status})`
190
+ return
191
+ }
192
+ await this.fetchFiles()
193
+ } catch (err) {
194
+ console.error('CnFilesTab: Failed to upload file', err)
195
+ this.uploadError = 'Upload failed: could not connect to server'
196
+ } finally {
197
+ this.loading = false
198
+ }
199
+ },
200
+
201
+ openFile(file) {
202
+ if (file.accessUrl) {
203
+ window.open(file.accessUrl, '_blank')
204
+ } else if (file.id) {
205
+ const dirPath = file.path ? file.path.substring(0, file.path.lastIndexOf('/')) : ''
206
+ const cleanPath = dirPath.replace(/^\/admin\/files\//, '/')
207
+ window.open(`/index.php/apps/files/files/${file.id}?dir=${encodeURIComponent(cleanPath)}&openfile=true`, '_blank')
208
+ }
209
+ },
210
+
211
+ async deleteFile(file) {
212
+ if (!this.register || !this.schema) return
213
+ try {
214
+ await fetch(
215
+ `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files/${file.id}`,
216
+ { method: 'DELETE', headers: buildHeaders() },
217
+ )
218
+ this.files = this.files.filter(f => f.id !== file.id)
219
+ } catch (err) {
220
+ console.error('CnFilesTab: Failed to delete file', err)
221
+ }
222
+ },
223
+
224
+ formatFileSize(bytes) {
225
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
226
+ if (!bytes || bytes === 0) return 'n/a'
227
+ const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)))
228
+ if (i === 0) return '< 1 KB'
229
+ return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i]
230
+ },
231
+ },
232
+ }
233
+ </script>
234
+
235
+ <style scoped>
236
+ .cn-sidebar-tab { padding: 12px; }
237
+
238
+ .cn-sidebar-tab__upload-error {
239
+ padding: 8px 12px;
240
+ margin-bottom: 8px;
241
+ border-radius: var(--border-radius, 4px);
242
+ background-color: var(--color-error-light, rgba(229, 57, 53, 0.1));
243
+ color: var(--color-error, #e53935);
244
+ font-size: 13px;
245
+ }
246
+
247
+ .cn-sidebar-tab__dropzone {
248
+ display: flex;
249
+ flex-direction: column;
250
+ align-items: center;
251
+ justify-content: center;
252
+ gap: 8px;
253
+ padding: 20px 12px;
254
+ margin-bottom: 16px;
255
+ border: 2px dashed var(--color-border);
256
+ border-radius: var(--border-radius-large, 8px);
257
+ cursor: pointer;
258
+ transition: border-color 0.15s ease, background-color 0.15s ease;
259
+ }
260
+
261
+ .cn-sidebar-tab__dropzone:hover {
262
+ border-color: var(--color-primary-element);
263
+ background-color: var(--color-primary-element-light, rgba(0, 130, 201, 0.08));
264
+ }
265
+
266
+ .cn-sidebar-tab__dropzone--active {
267
+ border-color: var(--color-primary-element);
268
+ background-color: var(--color-primary-element-light, rgba(0, 130, 201, 0.12));
269
+ }
270
+
271
+ .cn-sidebar-tab__dropzone-icon { color: var(--color-text-maxcontrast); }
272
+ .cn-sidebar-tab__dropzone--active .cn-sidebar-tab__dropzone-icon,
273
+ .cn-sidebar-tab__dropzone:hover .cn-sidebar-tab__dropzone-icon { color: var(--color-primary-element); }
274
+ .cn-sidebar-tab__dropzone-text { font-size: 13px; color: var(--color-text-maxcontrast); }
275
+ .cn-sidebar-tab__file-input { display: none; }
276
+
277
+ .cn-sidebar-tab__empty {
278
+ text-align: center;
279
+ padding: 24px 12px;
280
+ color: var(--color-text-maxcontrast);
281
+ font-size: 13px;
282
+ }
283
+
284
+ .cn-sidebar-tab__list { display: flex; flex-direction: column; gap: 2px; }
285
+ .cn-sidebar-tab__load-more { margin-top: 8px; }
286
+ </style>