@conduction/nextcloud-vue 2.96.0 → 2.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
  2. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -1
  3. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  4. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +383 -359
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  6. package/dist/esm/components/CnConversationThread/CnConversationThread.vue.js +1 -1
  7. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js +17 -3
  8. package/dist/esm/components/CnConversationThread/CnConversationThread.vue2.js.map +1 -1
  9. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js +61 -43
  10. package/dist/esm/components/CnConversationThread/CnConversationThread.vue3.js.map +1 -1
  11. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue.js +1 -1
  12. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js +159 -18
  13. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js +9 -7
  15. package/dist/esm/components/CnConversationThread/CnConversationWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  17. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +48 -3
  18. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  19. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +46 -12
  20. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
  22. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +144 -0
  23. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +488 -399
  25. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  27. package/dist/esm/components/CnFilesBrowser/filesBrowserColumns.js.map +1 -1
  28. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +301 -18
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +29 -7
  31. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  32. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
  33. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +2 -2
  34. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +2 -2
  36. package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/useNamedSource.js +1 -1
  38. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  39. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
  40. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +13 -0
  41. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  42. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +5 -1
  43. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  44. package/dist/esm/composables/indexSources.js +182 -3
  45. package/dist/esm/composables/indexSources.js.map +1 -1
  46. package/dist/esm/l10n/en.json.js +7 -1
  47. package/dist/esm/l10n/en.json.js.map +1 -1
  48. package/dist/esm/l10n/nl.json.js +7 -1
  49. package/dist/esm/l10n/nl.json.js.map +1 -1
  50. package/dist/esm/library-style.js +1 -1
  51. package/dist/esm/nextcloud-vue.css +301 -177
  52. package/dist/esm/utils/queryPresets.js +13 -2
  53. package/dist/esm/utils/queryPresets.js.map +1 -1
  54. package/dist/esm/utils/scopeListLayout.js +37 -1
  55. package/dist/esm/utils/scopeListLayout.js.map +1 -1
  56. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  57. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  58. package/dist/nextcloud-vue.cjs.js +3009 -1907
  59. package/dist/nextcloud-vue.cjs.js.map +2 -2
  60. package/dist/nextcloud-vue.css +301 -177
  61. package/l10n/en.json +7 -1
  62. package/l10n/nl.json +7 -1
  63. package/package.json +1 -1
  64. package/src/components/CnCellRenderer/CnCellRenderer.vue +57 -1
  65. package/src/components/CnConversationThread/CnConversationThread.md +12 -0
  66. package/src/components/CnConversationThread/CnConversationThread.vue +30 -4
  67. package/src/components/CnConversationThread/CnConversationWidget.vue +167 -24
  68. package/src/components/CnFactsWidget/CnFactsWidget.vue +114 -3
  69. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +508 -236
  70. package/src/components/CnFilesBrowser/filesBrowserColumns.js +8 -2
  71. package/src/components/CnIndexPage/CnIndexPage.vue +324 -24
  72. package/src/components/CnIndexPage/CnIndexPageWidgets.vue +13 -4
  73. package/src/components/CnIndexPage/useNamedSource.js +1 -1
  74. package/src/components/CnObjectSidebar/CnFilesTab.vue +17 -0
  75. package/src/composables/indexSources.js +182 -3
  76. package/src/schemas/app-manifest-v2.schema.json +11 -4
  77. package/src/utils/queryPresets.js +13 -2
  78. package/src/utils/scopeListLayout.js +37 -1
  79. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -183,8 +183,8 @@
183
183
  role="toolbar"
184
184
  :aria-label="t('nextcloud-vue', 'Actions for the selected files')"
185
185
  data-testid="cn-files-browser-bulk-bar">
186
- <span class="cn-files-browser__bulk-count" aria-live="polite">
187
- {{ t('nextcloud-vue', '{count} selected', { count: selectedIds.length }) }}
186
+ <span class="cn-files-browser__bulk-count" aria-live="polite" data-testid="cn-files-browser-bulk-count">
187
+ {{ selectionCountText }}
188
188
  </span>
189
189
  <NcButton v-for="action in bulkActions"
190
190
  :key="action.id"
@@ -195,7 +195,8 @@
195
195
  </template>
196
196
  {{ hostLabel(action) }}
197
197
  </NcButton>
198
- <NcButton variant="tertiary"
198
+ <NcButton v-if="showClearSelection"
199
+ variant="tertiary"
199
200
  data-testid="cn-files-browser-bulk-clear"
200
201
  @click="clearSelection">
201
202
  {{ t('nextcloud-vue', 'Clear selection') }}
@@ -233,269 +234,313 @@
233
234
  this page, offered under the same names and icons the Files app
234
235
  uses. What cannot run away from the Files page (the details
235
236
  sidebar) is left out, and Show in Files covers it. -->
236
- <table v-else class="cn-files-browser__table" data-testid="cn-files-browser-table">
237
- <thead>
238
- <tr>
239
- <th v-if="bulkEnabled" class="cn-files-browser__col-select" scope="col">
240
- <input type="checkbox"
241
- class="cn-files-browser__select"
242
- :checked="allSelected"
243
- :indeterminate.prop="someSelected && !allSelected"
244
- :disabled="selectableIds.length === 0"
245
- :aria-label="t('nextcloud-vue', 'Select all files')"
246
- data-testid="cn-files-browser-select-all"
247
- @change="toggleAll($event.target.checked)">
248
- </th>
249
- <th class="cn-files-browser__col-icon" scope="col">
250
- <span class="hidden-visually">{{ t('nextcloud-vue', 'Type') }}</span>
251
- </th>
252
- <th v-for="column in renderedColumns"
253
- :key="column.key"
254
- scope="col"
255
- :class="`cn-files-browser__col-${column.key}`"
256
- :aria-sort="ariaSort(sortTokenOf(column))"
257
- :data-testid="`cn-files-browser-header-${column.key}`">
258
- <button v-if="column.sortable !== false"
259
- type="button"
260
- class="cn-files-browser__sort"
261
- @click="sortBy(sortTokenOf(column))">
262
- {{ columnLabel(column) }}
263
- <component :is="sortIcon(sortTokenOf(column))" v-if="sortKey === sortTokenOf(column)" :size="16" />
264
- </button>
265
- <template v-else>
266
- {{ columnLabel(column) }}
267
- </template>
268
- </th>
269
- <th class="cn-files-browser__col-actions" scope="col">
270
- <span class="hidden-visually">{{ t('nextcloud-vue', 'Actions') }}</span>
271
- </th>
272
- </tr>
273
- </thead>
274
- <tbody>
275
- <!-- A heading row per group when the host groups on a column; the
276
- destructured entry is either a group or a node. -->
277
- <template v-for="{ key, group, node } in displayRows" :key="key">
278
- <tr v-if="group"
279
- class="cn-files-browser__group"
280
- data-testid="cn-files-browser-group">
281
- <th :colspan="columnCount" scope="rowgroup" class="cn-files-browser__group-heading">
282
- {{ group.label }}
283
- <span class="cn-files-browser__group-count">{{ group.count }}</span>
237
+ <div v-else
238
+ class="cn-files-browser__body"
239
+ :class="{ 'cn-files-browser__body--with-side': sideNode !== null }">
240
+ <table class="cn-files-browser__table" data-testid="cn-files-browser-table">
241
+ <thead>
242
+ <tr>
243
+ <th v-if="bulkEnabled" class="cn-files-browser__col-select" scope="col">
244
+ <input type="checkbox"
245
+ class="cn-files-browser__select"
246
+ :checked="allSelected"
247
+ :indeterminate.prop="someSelected && !allSelected"
248
+ :disabled="selectableIds.length === 0"
249
+ :aria-label="t('nextcloud-vue', 'Select all files')"
250
+ data-testid="cn-files-browser-select-all"
251
+ @change="toggleAll($event.target.checked)">
252
+ </th>
253
+ <th class="cn-files-browser__col-icon" scope="col">
254
+ <span class="hidden-visually">{{ t('nextcloud-vue', 'Type') }}</span>
255
+ </th>
256
+ <th v-for="column in renderedColumns"
257
+ :key="column.key"
258
+ scope="col"
259
+ :class="`cn-files-browser__col-${column.key}`"
260
+ :aria-sort="ariaSort(sortTokenOf(column))"
261
+ :data-testid="`cn-files-browser-header-${column.key}`">
262
+ <button v-if="column.sortable !== false"
263
+ type="button"
264
+ class="cn-files-browser__sort"
265
+ @click="sortBy(sortTokenOf(column))">
266
+ {{ columnLabel(column) }}
267
+ <component :is="sortIcon(sortTokenOf(column))" v-if="sortKey === sortTokenOf(column)" :size="16" />
268
+ </button>
269
+ <template v-else>
270
+ {{ columnLabel(column) }}
271
+ </template>
272
+ </th>
273
+ <th class="cn-files-browser__col-actions" scope="col">
274
+ <span class="hidden-visually">{{ t('nextcloud-vue', 'Actions') }}</span>
284
275
  </th>
285
276
  </tr>
277
+ </thead>
278
+ <tbody>
279
+ <!-- A heading row per group when the host groups on a column; the
280
+ destructured entry is either a group or a node. -->
281
+ <template v-for="{ key, group, node } in displayRows" :key="key">
282
+ <tr v-if="group"
283
+ class="cn-files-browser__group"
284
+ data-testid="cn-files-browser-group">
285
+ <th :colspan="columnCount" scope="rowgroup" class="cn-files-browser__group-heading">
286
+ {{ group.label }}
287
+ <span class="cn-files-browser__group-count">{{ group.count }}</span>
288
+ </th>
289
+ </tr>
290
+ <tr
291
+ v-else
292
+ :key="key"
293
+ class="cn-files-browser__row"
294
+ :class="{ 'cn-files-browser__row--folder': isFolder(node) }"
295
+ data-testid="cn-files-browser-row"
296
+ :data-name="node.basename"
297
+ @click="onRowClick(node, $event)"
298
+ @mousedown="preventMiddleClickAutoscroll"
299
+ @auxclick="onRowAuxClick(node, $event)">
300
+ <td v-if="bulkEnabled"
301
+ class="cn-files-browser__col-select"
302
+ @click.stop
303
+ @auxclick.stop>
304
+ <input v-if="!isFolder(node)"
305
+ type="checkbox"
306
+ class="cn-files-browser__select"
307
+ :checked="isSelected(node)"
308
+ :aria-label="t('nextcloud-vue', 'Select {name}', { name: node.basename })"
309
+ data-testid="cn-files-browser-select"
310
+ @change="toggleSelected(node, $event.target.checked)">
311
+ </td>
312
+ <td class="cn-files-browser__col-icon">
313
+ <img
314
+ v-if="previewUrlFor(node)"
315
+ class="cn-files-browser__thumb"
316
+ :src="previewUrlFor(node)"
317
+ alt=""
318
+ loading="lazy"
319
+ @error="onPreviewError(node)">
320
+ <img
321
+ v-else-if="mimeIconFor(node)"
322
+ class="cn-files-browser__mime"
323
+ :src="mimeIconFor(node)"
324
+ alt="">
325
+ <FolderOutline v-else-if="isFolder(node)" :size="32" />
326
+ <FileOutline v-else :size="32" />
327
+ </td>
328
+ <td v-for="column in renderedColumns"
329
+ :key="column.key"
330
+ :class="`cn-files-browser__col-${column.key}`"
331
+ :data-testid="`cn-files-browser-cell-${column.key}`">
332
+ <span v-if="column.key === 'name'" class="cn-files-browser__name">{{ node.basename }}</span>
333
+ <template v-else-if="column.key === 'size'">
334
+ {{ isFolder(node) ? '' : formatSize(node.size) }}
335
+ </template>
336
+ <NcDateTime v-else-if="column.key === 'modified'" :timestamp="node.mtime" :ignoreSeconds="true" />
337
+ <!-- Everything the host declared renders through the same
338
+ cell renderer the tables use, so a `formatter` means
339
+ here what it means there. -->
340
+ <CnCellRenderer v-else
341
+ :class="column.widget !== 'badge' && toneOf(column, node) ? `cn-files-browser__tone cn-files-browser__tone--${toneOf(column, node)}` : null"
342
+ :value="cellValue(column, node)"
343
+ :formatter="column.formatter || null"
344
+ :formatterOptions="column.formatterOptions || null"
345
+ :widget="column.widget || null"
346
+ :widgetProps="cellWidgetProps(column, node)"
347
+ :row="node" />
348
+ </td>
349
+ <td class="cn-files-browser__col-actions" @click.stop @auxclick.stop>
350
+ <NcActions :forceMenu="true" :ariaLabel="t('nextcloud-vue', 'Actions for {name}', { name: node.basename })">
351
+ <NcActionButton
352
+ v-for="action in actionsFor(node)"
353
+ :key="action.id"
354
+ :closeAfterClick="true"
355
+ :data-testid="`cn-files-browser-action-${action.id}`"
356
+ @click="run(action, node)">
357
+ <template #icon>
358
+ <NcIconSvgWrapper :svg="iconOf(action, node)" />
359
+ </template>
360
+ {{ labelOf(action, node) }}
361
+ </NcActionButton>
362
+ <!-- The host's own actions on a file (never on a folder): declared
363
+ in its manifest, dispatched through the page's action runner
364
+ with the node's file id, name and path merged in. -->
365
+ <template v-if="!isFolder(node)">
366
+ <NcActionButton
367
+ v-for="action in hostActionsFor(node)"
368
+ :key="`host-${action.id}`"
369
+ :closeAfterClick="true"
370
+ :data-testid="`cn-files-browser-host-action-${action.id}`"
371
+ @click="runHostAction(action, node)">
372
+ <template #icon>
373
+ <CnIcon :name="action.icon || 'FileDocumentEditOutline'" :size="20" />
374
+ </template>
375
+ {{ hostLabel(action) }}
376
+ </NcActionButton>
377
+ </template>
378
+ <NcActionSeparator v-if="actionsFor(node).length > 0 || (!isFolder(node) && hostActionsFor(node).length > 0)" />
379
+ <!-- The Files app's own rename is its list's inline input,
380
+ which is not here; this one is a dialog over a DAV move. -->
381
+ <!-- With a `source`, the Files app's actions (which act over
382
+ WebDAV) are not offered; download and delete go through
383
+ the source instead. -->
384
+ <NcActionLink
385
+ v-if="source && sourceDownloadUrl(node)"
386
+ :href="sourceDownloadUrl(node)"
387
+ :download="node.basename"
388
+ :closeAfterClick="true"
389
+ data-testid="cn-files-browser-action-download">
390
+ <template #icon>
391
+ <Download :size="20" />
392
+ </template>
393
+ {{ downloadLabel }}
394
+ </NcActionLink>
395
+ <NcActionButton v-if="source && canChange"
396
+ :closeAfterClick="true"
397
+ data-testid="cn-files-browser-action-delete"
398
+ @click="askRemove(node)">
399
+ <template #icon>
400
+ <TrashCanOutline :size="20" />
401
+ </template>
402
+ {{ deleteLabel }}
403
+ </NcActionButton>
404
+ <NcActionButton v-if="canChange"
405
+ :closeAfterClick="true"
406
+ data-testid="cn-files-browser-action-rename"
407
+ @click="askRename(node)">
408
+ <template #icon>
409
+ <Pencil :size="20" />
410
+ </template>
411
+ {{ renameLabel }}
412
+ </NcActionButton>
413
+ <NcActionLink
414
+ v-if="node.fileid && !source"
415
+ :href="permalink(node)"
416
+ target="_blank"
417
+ :closeAfterClick="true">
418
+ <template #icon>
419
+ <OpenInNew :size="20" />
420
+ </template>
421
+ {{ showInFilesLabel }}
422
+ </NcActionLink>
423
+ </NcActions>
424
+ </td>
425
+ </tr>
426
+ </template>
427
+ <tr v-if="facetActive && filteredFiles.length === 0" class="cn-files-browser__no-match-row">
428
+ <td :colspan="columnCount" class="cn-files-browser__no-match" data-testid="cn-files-browser-no-match">
429
+ {{ t('nextcloud-vue', 'No files match the filter.') }}
430
+ </td>
431
+ </tr>
432
+ <!-- Rows that are not nodes of this folder: documents the host
433
+ joined from elsewhere (another object's folder). Open and
434
+ download only; nothing here can change the file. -->
286
435
  <tr
287
- v-else
288
- :key="key"
289
- class="cn-files-browser__row"
290
- :class="{ 'cn-files-browser__row--folder': isFolder(node) }"
291
- data-testid="cn-files-browser-row"
292
- :data-name="node.basename"
293
- @click="onRowClick(node, $event)"
436
+ v-for="item in linkedItems"
437
+ :key="`linked-${item.id}`"
438
+ class="cn-files-browser__row cn-files-browser__row--linked"
439
+ data-testid="cn-files-browser-linked-row"
440
+ :data-name="item.name"
441
+ @click="openLinked(item)"
294
442
  @mousedown="preventMiddleClickAutoscroll"
295
- @auxclick="onRowAuxClick(node, $event)">
296
- <td v-if="bulkEnabled"
297
- class="cn-files-browser__col-select"
298
- @click.stop
299
- @auxclick.stop>
300
- <input v-if="!isFolder(node)"
301
- type="checkbox"
302
- class="cn-files-browser__select"
303
- :checked="isSelected(node)"
304
- :aria-label="t('nextcloud-vue', 'Select {name}', { name: node.basename })"
305
- data-testid="cn-files-browser-select"
306
- @change="toggleSelected(node, $event.target.checked)">
307
- </td>
443
+ @auxclick="onLinkedAuxClick(item, $event)">
444
+ <td v-if="bulkEnabled" class="cn-files-browser__col-select" />
308
445
  <td class="cn-files-browser__col-icon">
309
446
  <img
310
- v-if="previewUrlFor(node)"
311
- class="cn-files-browser__thumb"
312
- :src="previewUrlFor(node)"
313
- alt=""
314
- loading="lazy"
315
- @error="onPreviewError(node)">
316
- <img
317
- v-else-if="mimeIconFor(node)"
447
+ v-if="mimeIconFor({ mime: item.mime })"
318
448
  class="cn-files-browser__mime"
319
- :src="mimeIconFor(node)"
449
+ :src="mimeIconFor({ mime: item.mime })"
320
450
  alt="">
321
- <FolderOutline v-else-if="isFolder(node)" :size="32" />
322
451
  <FileOutline v-else :size="32" />
323
452
  </td>
453
+ <!-- A linked item is not a node of this folder, so it can only
454
+ answer for name, size and modified. It still walks the same
455
+ column list, or a declared column set would shift every
456
+ linked row one cell out of line with its header. -->
324
457
  <td v-for="column in renderedColumns"
325
458
  :key="column.key"
326
- :class="`cn-files-browser__col-${column.key}`"
327
- :data-testid="`cn-files-browser-cell-${column.key}`">
328
- <span v-if="column.key === 'name'" class="cn-files-browser__name">{{ node.basename }}</span>
459
+ :class="`cn-files-browser__col-${column.key}`">
460
+ <template v-if="column.key === 'name'">
461
+ <span class="cn-files-browser__name">{{ item.name }}</span>
462
+ <span v-if="item.note" class="cn-files-browser__note">
463
+ <a
464
+ v-if="item.noteHref"
465
+ :href="item.noteHref"
466
+ class="cn-files-browser__note-link"
467
+ @click.stop>{{ item.note }}</a>
468
+ <template v-else>{{ item.note }}</template>
469
+ </span>
470
+ </template>
329
471
  <template v-else-if="column.key === 'size'">
330
- {{ isFolder(node) ? '' : formatSize(node.size) }}
472
+ {{ item.size ? formatSize(item.size) : '' }}
331
473
  </template>
332
- <NcDateTime v-else-if="column.key === 'modified'" :timestamp="node.mtime" :ignoreSeconds="true" />
333
- <!-- Everything the host declared renders through the same
334
- cell renderer the tables use, so a `formatter` means
335
- here what it means there. -->
336
- <CnCellRenderer v-else
337
- :value="cellValue(column, node)"
338
- :formatter="column.formatter || null"
339
- :formatterOptions="column.formatterOptions || null"
340
- :widget="column.widget || null"
341
- :row="node" />
474
+ <NcDateTime v-else-if="column.key === 'modified' && item.mtime" :timestamp="item.mtime" :ignoreSeconds="true" />
342
475
  </td>
343
476
  <td class="cn-files-browser__col-actions" @click.stop @auxclick.stop>
344
- <NcActions :forceMenu="true" :ariaLabel="t('nextcloud-vue', 'Actions for {name}', { name: node.basename })">
345
- <NcActionButton
346
- v-for="action in actionsFor(node)"
347
- :key="action.id"
477
+ <NcActions :forceMenu="true" :ariaLabel="t('nextcloud-vue', 'Actions for {name}', { name: item.name })">
478
+ <NcActionLink
479
+ v-if="item.href"
480
+ :href="item.href"
481
+ target="_blank"
348
482
  :closeAfterClick="true"
349
- :data-testid="`cn-files-browser-action-${action.id}`"
350
- @click="run(action, node)">
483
+ data-testid="cn-files-browser-linked-open">
351
484
  <template #icon>
352
- <NcIconSvgWrapper :svg="iconOf(action, node)" />
485
+ <OpenInNew :size="20" />
353
486
  </template>
354
- {{ labelOf(action, node) }}
355
- </NcActionButton>
356
- <!-- The host's own actions on a file (never on a folder): declared
357
- in its manifest, dispatched through the page's action runner
358
- with the node's file id, name and path merged in. -->
359
- <template v-if="!isFolder(node)">
360
- <NcActionButton
361
- v-for="action in hostActionsFor(node)"
362
- :key="`host-${action.id}`"
363
- :closeAfterClick="true"
364
- :data-testid="`cn-files-browser-host-action-${action.id}`"
365
- @click="runHostAction(action, node)">
366
- <template #icon>
367
- <CnIcon :name="action.icon || 'FileDocumentEditOutline'" :size="20" />
368
- </template>
369
- {{ hostLabel(action) }}
370
- </NcActionButton>
371
- </template>
372
- <NcActionSeparator v-if="actionsFor(node).length > 0 || (!isFolder(node) && hostActionsFor(node).length > 0)" />
373
- <!-- The Files app's own rename is its list's inline input,
374
- which is not here; this one is a dialog over a DAV move. -->
375
- <!-- With a `source`, the Files app's actions (which act over
376
- WebDAV) are not offered; download and delete go through
377
- the source instead. -->
487
+ {{ openLinkedLabel }}
488
+ </NcActionLink>
378
489
  <NcActionLink
379
- v-if="source && sourceDownloadUrl(node)"
380
- :href="sourceDownloadUrl(node)"
381
- :download="node.basename"
490
+ v-if="item.downloadHref"
491
+ :href="item.downloadHref"
492
+ :download="item.name"
382
493
  :closeAfterClick="true"
383
- data-testid="cn-files-browser-action-download">
494
+ data-testid="cn-files-browser-linked-download">
384
495
  <template #icon>
385
496
  <Download :size="20" />
386
497
  </template>
387
498
  {{ downloadLabel }}
388
499
  </NcActionLink>
389
- <NcActionButton v-if="source && canChange"
390
- :closeAfterClick="true"
391
- data-testid="cn-files-browser-action-delete"
392
- @click="askRemove(node)">
393
- <template #icon>
394
- <TrashCanOutline :size="20" />
395
- </template>
396
- {{ deleteLabel }}
397
- </NcActionButton>
398
- <NcActionButton v-if="canChange"
399
- :closeAfterClick="true"
400
- data-testid="cn-files-browser-action-rename"
401
- @click="askRename(node)">
402
- <template #icon>
403
- <Pencil :size="20" />
404
- </template>
405
- {{ renameLabel }}
406
- </NcActionButton>
407
- <NcActionLink
408
- v-if="node.fileid && !source"
409
- :href="permalink(node)"
410
- target="_blank"
411
- :closeAfterClick="true">
412
- <template #icon>
413
- <OpenInNew :size="20" />
414
- </template>
415
- {{ showInFilesLabel }}
416
- </NcActionLink>
417
500
  </NcActions>
418
501
  </td>
419
502
  </tr>
420
- </template>
421
- <tr v-if="facetActive && filteredFiles.length === 0" class="cn-files-browser__no-match-row">
422
- <td :colspan="columnCount" class="cn-files-browser__no-match" data-testid="cn-files-browser-no-match">
423
- {{ t('nextcloud-vue', 'No files match the filter.') }}
424
- </td>
425
- </tr>
426
- <!-- Rows that are not nodes of this folder: documents the host
427
- joined from elsewhere (another object's folder). Open and
428
- download only; nothing here can change the file. -->
429
- <tr
430
- v-for="item in linkedItems"
431
- :key="`linked-${item.id}`"
432
- class="cn-files-browser__row cn-files-browser__row--linked"
433
- data-testid="cn-files-browser-linked-row"
434
- :data-name="item.name"
435
- @click="openLinked(item)"
436
- @mousedown="preventMiddleClickAutoscroll"
437
- @auxclick="onLinkedAuxClick(item, $event)">
438
- <td v-if="bulkEnabled" class="cn-files-browser__col-select" />
439
- <td class="cn-files-browser__col-icon">
440
- <img
441
- v-if="mimeIconFor({ mime: item.mime })"
442
- class="cn-files-browser__mime"
443
- :src="mimeIconFor({ mime: item.mime })"
444
- alt="">
445
- <FileOutline v-else :size="32" />
446
- </td>
447
- <!-- A linked item is not a node of this folder, so it can only
448
- answer for name, size and modified. It still walks the same
449
- column list, or a declared column set would shift every
450
- linked row one cell out of line with its header. -->
451
- <td v-for="column in renderedColumns"
452
- :key="column.key"
453
- :class="`cn-files-browser__col-${column.key}`">
454
- <template v-if="column.key === 'name'">
455
- <span class="cn-files-browser__name">{{ item.name }}</span>
456
- <span v-if="item.note" class="cn-files-browser__note">
457
- <a
458
- v-if="item.noteHref"
459
- :href="item.noteHref"
460
- class="cn-files-browser__note-link"
461
- @click.stop>{{ item.note }}</a>
462
- <template v-else>{{ item.note }}</template>
463
- </span>
464
- </template>
465
- <template v-else-if="column.key === 'size'">
466
- {{ item.size ? formatSize(item.size) : '' }}
503
+ </tbody>
504
+ </table>
505
+
506
+ <!-- `sideCards`: cards about the last selected file, beside the list. -->
507
+ <aside v-if="sideNode !== null"
508
+ class="cn-files-browser__side"
509
+ :aria-label="t('nextcloud-vue', 'About {name}', { name: sideNode.basename })"
510
+ data-testid="cn-files-browser-side">
511
+ <section v-for="card in sideCardsFor(sideNode)"
512
+ :key="card.id"
513
+ class="cn-files-browser__side-card"
514
+ :data-testid="`cn-files-browser-side-card-${card.id}`">
515
+ <h3 class="cn-files-browser__side-title">
516
+ {{ card.title }}
517
+ </h3>
518
+ <dl v-if="card.fields.length > 0" class="cn-files-browser__side-facts">
519
+ <template v-for="field in card.fields" :key="field.key">
520
+ <dt>{{ field.label }}</dt>
521
+ <dd :class="field.tone ? `cn-files-browser__tone cn-files-browser__tone--${field.tone}` : ''">
522
+ <CnCellRenderer
523
+ :value="field.value"
524
+ :formatter="field.formatter || null"
525
+ :formatterOptions="field.formatterOptions || null"
526
+ :row="sideNode" />
527
+ </dd>
467
528
  </template>
468
- <NcDateTime v-else-if="column.key === 'modified' && item.mtime" :timestamp="item.mtime" :ignoreSeconds="true" />
469
- </td>
470
- <td class="cn-files-browser__col-actions" @click.stop @auxclick.stop>
471
- <NcActions :forceMenu="true" :ariaLabel="t('nextcloud-vue', 'Actions for {name}', { name: item.name })">
472
- <NcActionLink
473
- v-if="item.href"
474
- :href="item.href"
475
- target="_blank"
476
- :closeAfterClick="true"
477
- data-testid="cn-files-browser-linked-open">
478
- <template #icon>
479
- <OpenInNew :size="20" />
480
- </template>
481
- {{ openLinkedLabel }}
482
- </NcActionLink>
483
- <NcActionLink
484
- v-if="item.downloadHref"
485
- :href="item.downloadHref"
486
- :download="item.name"
487
- :closeAfterClick="true"
488
- data-testid="cn-files-browser-linked-download">
489
- <template #icon>
490
- <Download :size="20" />
491
- </template>
492
- {{ downloadLabel }}
493
- </NcActionLink>
494
- </NcActions>
495
- </td>
496
- </tr>
497
- </tbody>
498
- </table>
529
+ </dl>
530
+ <div v-if="card.actions.length > 0" class="cn-files-browser__side-actions">
531
+ <NcButton v-for="action in card.actions"
532
+ :key="action.id"
533
+ :data-testid="`cn-files-browser-side-action-${action.id}`"
534
+ @click="runHostAction(action, sideNode)">
535
+ <template v-if="action.icon" #icon>
536
+ <CnIcon :name="action.icon" :size="20" />
537
+ </template>
538
+ {{ hostLabel(action) }}
539
+ </NcButton>
540
+ </div>
541
+ </section>
542
+ </aside>
543
+ </div>
499
544
 
500
545
  <p v-if="dropHint && canChange && !loading && error === ''"
501
546
  class="cn-files-browser__drop-hint"
@@ -980,6 +1025,46 @@ export default {
980
1025
  default: () => [],
981
1026
  },
982
1027
 
1028
+ /**
1029
+ * The bulk bar's count, as a translatable template with `{count}`
1030
+ * ("{count} documents selected"); `selectionLabelOne` is used for one.
1031
+ * Empty reads "{count} selected".
1032
+ *
1033
+ * @spec openspec/changes/files-browser-side-cards/specs/files-browser/spec.md#requirement-the-bulk-bar-names-what-is-selected
1034
+ */
1035
+ selectionLabel: {
1036
+ type: String,
1037
+ default: '',
1038
+ },
1039
+
1040
+ /** The bulk bar's count for one selected file, e.g. "{count} document selected". */
1041
+ selectionLabelOne: {
1042
+ type: String,
1043
+ default: '',
1044
+ },
1045
+
1046
+ /** Whether the bulk bar ends in a "Clear selection" button. */
1047
+ showClearSelection: {
1048
+ type: Boolean,
1049
+ default: true,
1050
+ },
1051
+
1052
+ /**
1053
+ * Cards beside the list about the last selected file:
1054
+ * `[{ id, title, fields: [{ key, label, source?, formatter?, formatterOptions?, colorMap?, colorKey? }], actions? }]`.
1055
+ * `title` may hold `{name}`, the file's name. A field reads `rowData`
1056
+ * (`source: 'row'`, the default) or the node (`source: 'node'`); a field
1057
+ * with no value is left out. `actions` are declared like `rowActions`
1058
+ * and run on that file. No side column while nothing is selected.
1059
+ *
1060
+ * @type {Array<{id: string, title: string, fields?: Array<object>, actions?: Array<object>}>}
1061
+ * @spec openspec/changes/files-browser-side-cards/specs/files-browser/spec.md#requirement-cards-beside-the-list-describe-the-selected-file
1062
+ */
1063
+ sideCards: {
1064
+ type: Array,
1065
+ default: () => [],
1066
+ },
1067
+
983
1068
  /**
984
1069
  * The key of a declared column to group the files on: a heading row
985
1070
  * per value, in the order each is first met, with its count. Files
@@ -1212,6 +1297,36 @@ export default {
1212
1297
  *
1213
1298
  * @return {Array<object>} The file nodes.
1214
1299
  */
1300
+ /**
1301
+ * The bulk bar's count text.
1302
+ *
1303
+ * @return {string}
1304
+ * @spec openspec/changes/files-browser-side-cards/specs/files-browser/spec.md#requirement-the-bulk-bar-names-what-is-selected
1305
+ */
1306
+ selectionCountText() {
1307
+ const count = this.selectedIds.length
1308
+ const template = count === 1 && this.selectionLabelOne ? this.selectionLabelOne : this.selectionLabel
1309
+ if (template) {
1310
+ return this.cnTranslate(template).replace(/\{count\}/g, String(count))
1311
+ }
1312
+ return t('nextcloud-vue', '{count} selected', { count })
1313
+ },
1314
+
1315
+ /**
1316
+ * The file the side cards describe: the last one selected, or null
1317
+ * without side cards or a selection.
1318
+ *
1319
+ * @return {object|null}
1320
+ * @spec openspec/changes/files-browser-side-cards/specs/files-browser/spec.md#requirement-cards-beside-the-list-describe-the-selected-file
1321
+ */
1322
+ sideNode() {
1323
+ if (!Array.isArray(this.sideCards) || this.sideCards.length === 0 || this.selectedIds.length === 0) {
1324
+ return null
1325
+ }
1326
+ const id = this.selectedIds[this.selectedIds.length - 1]
1327
+ return this.fileNodes.find((node) => String(node.fileid) === id) || null
1328
+ },
1329
+
1215
1330
  fileNodes() {
1216
1331
  return this.sorted.filter((node) => !this.isFolder(node))
1217
1332
  },
@@ -2098,6 +2213,80 @@ export default {
2098
2213
  return fileColumnValue(column, node, this.resolvedRowData)
2099
2214
  },
2100
2215
 
2216
+ /**
2217
+ * The tone a column or card field draws a value in: its `colorMap`
2218
+ * entry for the value (case-insensitive), read from the `colorKey` field
2219
+ * of the row when given, else from the value itself. '' without one.
2220
+ *
2221
+ * @param {object} column A resolved column or a side card field.
2222
+ * @param {object} node The node.
2223
+ * @return {string} A badge variant (`success`, `warning`, `error`, ...) or ''.
2224
+ * @spec openspec/changes/files-browser-side-cards/specs/files-browser/spec.md#requirement-a-row-column-can-draw-its-values-in-tones
2225
+ */
2226
+ toneOf(column, node) {
2227
+ const map = column && column.colorMap
2228
+ if (!map || typeof map !== 'object') {
2229
+ return ''
2230
+ }
2231
+ const raw = column.colorKey
2232
+ ? fileColumnValue({ key: column.colorKey, source: 'row' }, node, this.resolvedRowData)
2233
+ : this.cellValue(column, node)
2234
+ if (raw === null || raw === undefined || typeof raw === 'object') {
2235
+ return ''
2236
+ }
2237
+ const wanted = String(raw).toLowerCase()
2238
+ const hit = Object.keys(map).find((key) => key.toLowerCase() === wanted)
2239
+ return hit ? String(map[hit]) : ''
2240
+ },
2241
+
2242
+ /**
2243
+ * The widget props a cell gets: the column's own, and for a `badge`
2244
+ * its tone as the variant.
2245
+ *
2246
+ * @param {object} column A resolved column.
2247
+ * @param {object} node The node.
2248
+ * @return {object|null}
2249
+ */
2250
+ cellWidgetProps(column, node) {
2251
+ if (column.widget !== 'badge') {
2252
+ return column.widgetProps || null
2253
+ }
2254
+ const tone = this.toneOf(column, node)
2255
+ return { ...(column.widgetProps || {}), ...(tone ? { variant: tone } : {}) }
2256
+ },
2257
+
2258
+ /**
2259
+ * The side cards for a file, resolved: the title with `{name}` filled
2260
+ * in, the fields that have a value with their tone, and the actions.
2261
+ *
2262
+ * @param {object} node The node.
2263
+ * @return {Array<{id: string, title: string, fields: Array<object>, actions: Array<object>}>}
2264
+ * @spec openspec/changes/files-browser-side-cards/specs/files-browser/spec.md#requirement-cards-beside-the-list-describe-the-selected-file
2265
+ */
2266
+ sideCardsFor(node) {
2267
+ return this.sideCards
2268
+ .filter((card) => card && card.id)
2269
+ .map((card) => ({
2270
+ id: String(card.id),
2271
+ title: this.cnTranslate(String(card.title || '')).replace(/\{name\}/g, node.basename),
2272
+ fields: (Array.isArray(card.fields) ? card.fields : [])
2273
+ .filter((field) => field && field.key)
2274
+ .map((field) => {
2275
+ const column = { ...field, source: field.source || 'row' }
2276
+ return {
2277
+ key: String(field.key),
2278
+ label: this.cnTranslate(String(field.label || field.key)),
2279
+ value: this.cellValue(column, node),
2280
+ formatter: field.formatter,
2281
+ formatterOptions: field.formatterOptions,
2282
+ tone: this.toneOf(column, node),
2283
+ }
2284
+ })
2285
+ .filter((field) => field.value !== null && field.value !== undefined && field.value !== ''),
2286
+ actions: (Array.isArray(card.actions) ? card.actions : []).filter((action) => action && action.id),
2287
+ }))
2288
+ },
2289
+
2101
2290
  /**
2102
2291
  * Whether a user currently sees a column, for the chooser's ticks.
2103
2292
  *
@@ -2577,6 +2766,89 @@ export default {
2577
2766
  padding: 24px 0;
2578
2767
  }
2579
2768
 
2769
+ /* `sideCards`: the list and, while a file is selected, a column of cards. */
2770
+ .cn-files-browser__body--with-side {
2771
+ display: grid;
2772
+ grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
2773
+ align-items: start;
2774
+ gap: 16px;
2775
+ }
2776
+
2777
+ @media (max-width: 900px) {
2778
+ .cn-files-browser__body--with-side {
2779
+ grid-template-columns: minmax(0, 1fr);
2780
+ }
2781
+ }
2782
+
2783
+ .cn-files-browser__side {
2784
+ display: flex;
2785
+ flex-direction: column;
2786
+ gap: 12px;
2787
+ }
2788
+
2789
+ .cn-files-browser__side-card {
2790
+ display: flex;
2791
+ flex-direction: column;
2792
+ gap: 10px;
2793
+ padding: 16px 18px;
2794
+ border: 1px solid var(--color-border);
2795
+ border-radius: var(--border-radius-large);
2796
+ background: var(--color-main-background);
2797
+ }
2798
+
2799
+ .cn-files-browser__side-title {
2800
+ margin: 0;
2801
+ font-size: 15px;
2802
+ font-weight: 700;
2803
+ overflow-wrap: anywhere;
2804
+ }
2805
+
2806
+ .cn-files-browser__side-facts {
2807
+ display: grid;
2808
+ grid-template-columns: max-content minmax(0, 1fr);
2809
+ gap: 6px 12px;
2810
+ margin: 0;
2811
+ font-size: 14px;
2812
+ }
2813
+
2814
+ .cn-files-browser__side-facts dt {
2815
+ color: var(--color-text-maxcontrast);
2816
+ }
2817
+
2818
+ .cn-files-browser__side-facts dd {
2819
+ margin: 0;
2820
+ overflow-wrap: anywhere;
2821
+ }
2822
+
2823
+ .cn-files-browser__side-actions {
2824
+ display: flex;
2825
+ flex-wrap: wrap;
2826
+ gap: 8px;
2827
+ }
2828
+
2829
+ /* A value drawn in a tone (`colorMap` on a column that is not a badge): the
2830
+ text in the tone's colour, as the boards write a restricted level. */
2831
+ .cn-files-browser__tone {
2832
+ font-weight: 600;
2833
+ }
2834
+
2835
+ .cn-files-browser__tone--success {
2836
+ color: var(--color-success-text, var(--color-success));
2837
+ }
2838
+
2839
+ .cn-files-browser__tone--warning {
2840
+ color: var(--color-warning-text, var(--color-warning));
2841
+ }
2842
+
2843
+ .cn-files-browser__tone--error {
2844
+ color: var(--color-error-text, var(--color-error));
2845
+ }
2846
+
2847
+ .cn-files-browser__tone--primary,
2848
+ .cn-files-browser__tone--info {
2849
+ color: var(--color-primary-element-light-text, var(--color-primary-element));
2850
+ }
2851
+
2580
2852
  .cn-files-browser__table {
2581
2853
  width: 100%;
2582
2854
  border-collapse: collapse;