@bagelink/vue 1.15.253 → 1.15.261

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 (34) hide show
  1. package/dist/components/FilterQuery.vue.d.ts +6 -0
  2. package/dist/components/FilterQuery.vue.d.ts.map +1 -1
  3. package/dist/components/charts/ChartTooltip.vue.d.ts.map +1 -1
  4. package/dist/components/form/inputs/Upload/attachments.d.ts +27 -0
  5. package/dist/components/form/inputs/Upload/attachments.d.ts.map +1 -0
  6. package/dist/components/form/inputs/Upload/upload.d.ts +7 -3
  7. package/dist/components/form/inputs/Upload/upload.d.ts.map +1 -1
  8. package/dist/components/form/inputs/Upload/upload.types.d.ts +13 -0
  9. package/dist/components/form/inputs/Upload/upload.types.d.ts.map +1 -1
  10. package/dist/components/form/inputs/Upload/useAttachments.d.ts +97 -0
  11. package/dist/components/form/inputs/Upload/useAttachments.d.ts.map +1 -0
  12. package/dist/components/form/inputs/index.d.ts +2 -0
  13. package/dist/components/form/inputs/index.d.ts.map +1 -1
  14. package/dist/form-flow/FormFlowPlayer.vue.d.ts +8 -10
  15. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
  16. package/dist/form-flow/structure.d.ts +11 -7
  17. package/dist/form-flow/structure.d.ts.map +1 -1
  18. package/dist/index.cjs +92 -92
  19. package/dist/index.mjs +9546 -9424
  20. package/dist/style.css +1 -1
  21. package/package.json +1 -1
  22. package/src/components/Dropdown.vue +2 -2
  23. package/src/components/FilterQuery.vue +99 -12
  24. package/src/components/ListItem.vue +1 -1
  25. package/src/components/charts/ChartTooltip.vue +39 -4
  26. package/src/components/dataTable/DataTable.vue +1 -1
  27. package/src/components/form/inputs/Upload/attachments.ts +41 -0
  28. package/src/components/form/inputs/Upload/upload.ts +19 -10
  29. package/src/components/form/inputs/Upload/upload.types.ts +18 -0
  30. package/src/components/form/inputs/Upload/useAttachments.ts +175 -0
  31. package/src/components/form/inputs/index.ts +2 -0
  32. package/src/form-flow/FormFlowPlayer.vue +28 -26
  33. package/src/form-flow/structure.ts +12 -7
  34. package/src/styles/dark.css +3 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.253",
4
+ "version": "1.15.261",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -407,14 +407,14 @@ defineExpose({ show, hide, shown })
407
407
  <div
408
408
  :id="bglId" ref="popoverRef" popover="manual" class="bgl-dropdown m-0 p-0 border-none"
409
409
  role="menu"
410
- style="border-radius: var(--bgl-card-border-radius);"
410
+ style="border-radius: calc(var(--bgl-card-border-radius) * 0.7);"
411
411
  :class="[props.popupClass, { 'bgl-dropdown--no-positioning': shouldDisablePositioning, 'bg-white shadow': props.card }]"
412
412
  @toggle="onPopoverToggle" @mouseenter="onPopoverMouseenter" @mouseleave="onPopoverMouseleave"
413
413
  @focus="onPopoverMouseenter" @blur="onPopoverMouseleave" @keydown="onPopoverKeydown"
414
414
  >
415
415
  <div class="bgl-dropdown__backdrop" />
416
416
  <div class="bgl-dropdown__content overflow-hidden" :class="{ 'display-flex column bgl-dropdown__content--card': props.card }"
417
- style="border-radius: var(--bgl-card-border-radius);">
417
+ style="border-radius: calc(var(--bgl-card-border-radius) * 0.7);">
418
418
  <slot :hide :show :shown />
419
419
  </div>
420
420
  </div>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { FilterField, QueryOption, OptionsSource } from './FilterQuery.types'
3
+ import type { AlignedPlacement } from './Dropdown.vue'
3
4
  export type { FilterField, QueryOption, OptionsSource }
4
5
  </script>
5
6
 
@@ -17,9 +18,18 @@ interface UICondition {
17
18
  connector: LogicalOperator | ''
18
19
  }
19
20
 
20
- const props = defineProps<{
21
+ const props = withDefaults(defineProps<{
21
22
  fields: FilterField[]
22
- }>()
23
+ placement?: AlignedPlacement
24
+ /** Render the filter controls directly instead of inside a dropdown. */
25
+ inline?: boolean
26
+ /** Keep the desktop row layout and controls on mobile screens. */
27
+ desktopLayoutOnMobile?: boolean
28
+ }>(), {
29
+ placement: 'bottom-end',
30
+ inline: false,
31
+ desktopLayoutOnMobile: false,
32
+ })
23
33
 
24
34
  const emit = defineEmits<{
25
35
  change: [value: QueryConditions<T>]
@@ -35,6 +45,20 @@ const internalModel = ref<QueryConditions<T>>(collapseToPresets([...model.value]
35
45
  const { $t } = useI18n()
36
46
  const instance = getCurrentInstance()
37
47
  const hasSaveListener = computed(() => !!instance?.vnode.props?.onSave)
48
+ const filterLabel = computed(() =>
49
+ activeFiltersCount.value > 0
50
+ ? `${currentTexts.value.filter} (${activeFiltersCount.value})`
51
+ : currentTexts.value.filter,
52
+ )
53
+ const dropdownProps = computed(() => props.inline
54
+ ? {}
55
+ : {
56
+ flat: true,
57
+ placement: props.placement,
58
+ icon: 'filter_alt',
59
+ value: filterLabel.value,
60
+ thin: true,
61
+ })
38
62
 
39
63
  function isComplete(c: { field: string, op: ComparisonOperator, value?: any }) {
40
64
  if (!c.field || !c.op) return false
@@ -369,11 +393,16 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
369
393
  </script>
370
394
 
371
395
  <template>
372
- <Dropdown
373
- flat placement="bottom-end" icon="filter_alt"
374
- :value="activeFiltersCount > 0 ? `${currentTexts.filter} (${activeFiltersCount})` : currentTexts.filter" thin
396
+ <component
397
+ :is="props.inline ? 'div' : Dropdown"
398
+ v-bind="dropdownProps"
399
+ class="filter-query-wrap"
400
+ :class="{
401
+ 'filter-query-inline': props.inline,
402
+ 'filter-query-desktop-mobile': props.inline || props.desktopLayoutOnMobile,
403
+ }"
375
404
  >
376
- <Card class="p-05">
405
+ <component :is="props.inline ? 'div' : Card" :class="{ 'p-05': !props.inline }">
377
406
  <div class="p-025 bg-bg radius-1 mb-05 m_max-h-300px m_overflow m_border">
378
407
  <!-- Empty state -->
379
408
  <div v-if="conditions.length === 0" class="opacity-3 flex gap-05 p-025 min-w-450px m_min-0">
@@ -402,12 +431,14 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
402
431
  @update:model-value="(v: LogicalOperator) => onConnectorChange(condition.id, v)"
403
432
  />
404
433
  </div>
405
- <!-- <div v-else /> -->
406
434
 
407
435
  <!-- Field selector -->
408
436
  <SelectInput
409
437
  :model-value="condition.field" :options="fields"
410
- :placeholder="currentTexts.placeholders.selectField" class="m-0 light-input borderHover" searchable
438
+ :placeholder="currentTexts.placeholders.selectField"
439
+ class="m-0 light-input borderHover"
440
+ :class="{ 'first-filter-field': index === 0 }"
441
+ searchable
411
442
  @update:model-value="(v: string) => onFieldChange(condition.id, v)"
412
443
  />
413
444
 
@@ -486,7 +517,8 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
486
517
 
487
518
  <!-- Remove button -->
488
519
  <Btn
489
- icon="close" thin flat size="sm" class="m_bg-gray-40 m_px-3 m_mx-auto"
520
+ icon="close" thin flat size="sm"
521
+ :class="{ 'm_bg-gray-40 m_px-3 m_mx-auto': !props.inline && !props.desktopLayoutOnMobile }"
490
522
  @click="removeCondition(index)"
491
523
  />
492
524
  </div>
@@ -507,12 +539,15 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
507
539
  />
508
540
  </div>
509
541
  </div>
510
- </Card>
511
- </Dropdown>
542
+ </component>
543
+ </component>
512
544
  </template>
513
545
 
514
546
  <style>
515
-
547
+ .filter-query-wrap{
548
+ font-size: var(--bgl-input-font-size);
549
+ --bgl-radius:var(--bgl-input-border-radius)
550
+ }
516
551
  .and-or-select {
517
552
  --bgl-input-font-size: 12px !important;
518
553
  --input-background-color: transparent !important;
@@ -536,6 +571,44 @@ padding-top: 0 !important;
536
571
  grid-template-columns: 50px minmax(90px, 0.5fr) minmax(170px, 0.75fr) minmax(120px, 0.5fr) auto;
537
572
  }
538
573
 
574
+ .filter-query-inline {
575
+ width: 100%;
576
+ max-width: 100%;
577
+ min-width: 0;
578
+ box-sizing: border-box;
579
+ }
580
+
581
+ .filter-query-inline > *,
582
+ .filter-query-inline .m_overflow,
583
+ .filter-query-inline .first-filter-row > *,
584
+ .filter-query-inline .filter-row > * {
585
+ max-width: 100%;
586
+ min-width: 0;
587
+ box-sizing: border-box;
588
+ }
589
+
590
+ .filter-query-inline .m_overflow {
591
+ overflow-x: hidden;
592
+ overflow-y: visible;
593
+ max-height: none;
594
+ }
595
+
596
+ .filter-query-inline .filter-row {
597
+ grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
598
+ }
599
+
600
+ .filter-query-inline .first-filter-row {
601
+ grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
602
+ }
603
+
604
+ .filter-query-inline .first-filter-field {
605
+ grid-column: span 2;
606
+ }
607
+
608
+ .filter-query-inline .min-w-450px {
609
+ min-width: 0 !important;
610
+ }
611
+
539
612
  /* Transition animations */
540
613
  .condition-enter-active,
541
614
  .condition-leave-active {
@@ -566,6 +639,20 @@ transform: translateX(-10px);
566
639
  .first-filter-row {
567
640
  grid-template-columns: auto;
568
641
  }
642
+
643
+ .filter-query-desktop-mobile .filter-row,
644
+ .filter-query-desktop-mobile .first-filter-row {
645
+ grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
646
+ }
647
+
648
+ .filter-query-desktop-mobile .first-filter-row > *,
649
+ .filter-query-desktop-mobile .filter-row > * {
650
+ min-width: 0;
651
+ }
652
+
653
+ .filter-query-desktop-mobile .first-filter-field {
654
+ grid-column: span 2;
655
+ }
569
656
  }
570
657
 
571
658
  </style>
@@ -344,7 +344,7 @@ content: '';
344
344
  position: absolute;
345
345
  inset-inline: 0;
346
346
  bottom: 0;
347
- border-bottom: 1px solid var(--bgl-border-color);
347
+ border-bottom: 1px solid color-mix(in srgb, var(--bgl-border-color) 50%, transparent);
348
348
  }
349
349
  .list-item-row:last-child::after,
350
350
  .list-item-row.no-border-list::after {
@@ -13,7 +13,7 @@
13
13
  * - `prefer="top"`: pinned to the top of the chart (Line, Bar) — host sets
14
14
  * `{ left }` only; no vertical flip.
15
15
  */
16
- import { nextTick, ref, watch } from 'vue'
16
+ import { computed, nextTick, onMounted, onUpdated, ref, watch } from 'vue'
17
17
  import { useI18n } from '@bagelink/vue'
18
18
 
19
19
  export interface TipRow { name: string; color?: string; value: string }
@@ -56,6 +56,40 @@ function preferredSide(): 'above' | 'below' | 'top' {
56
56
  const el = ref<HTMLElement>()
57
57
  // The side actually used after measuring (may differ from the preferred one).
58
58
  const side = ref<'above' | 'below' | 'top'>(preferredSide())
59
+ const shiftX = ref(0)
60
+ const shiftStyle = computed(() => ({ '--bgl-tip-shift-x': `${shiftX.value}px` }))
61
+
62
+ /**
63
+ * Keep the tooltip inside its chart. Positioning starts centered on the cursor,
64
+ * then this correction nudges it away from either horizontal edge.
65
+ */
66
+ function clampHorizontal() {
67
+ const node = el.value
68
+ const container = node?.parentElement
69
+ if (!node || !container) return
70
+
71
+ const tip = node.getBoundingClientRect()
72
+ const bounds = container.getBoundingClientRect()
73
+ const padding = 8
74
+ const naturalLeft = tip.left - shiftX.value
75
+ const naturalRight = tip.right - shiftX.value
76
+ let nextShift = 0
77
+
78
+ if (naturalLeft < bounds.left + padding) {
79
+ nextShift = bounds.left + padding - naturalLeft
80
+ } else if (naturalRight > bounds.right - padding) {
81
+ nextShift = bounds.right - padding - naturalRight
82
+ }
83
+
84
+ if (Math.abs(nextShift - shiftX.value) > 0.5) shiftX.value = nextShift
85
+ }
86
+
87
+ function queueHorizontalClamp() {
88
+ nextTick(clampHorizontal)
89
+ }
90
+
91
+ onMounted(queueHorizontalClamp)
92
+ onUpdated(queueHorizontalClamp)
59
93
 
60
94
  /**
61
95
  * Place the tooltip by the anchor's position in the viewport:
@@ -93,6 +127,7 @@ watch(
93
127
  ref="el"
94
128
  class="bgl-tip absolute top-0 pointer-events-none nowrap z-5 color-white line-height-16"
95
129
  :class="{ 'bgl-tip--above': side === 'above', 'bgl-tip--below': side === 'below' }"
130
+ :style="shiftStyle"
96
131
  >
97
132
  <p v-if="label" class="opacity-7 txt-075rem m-0 pb-025 ">{{ label }}</p>
98
133
  <p v-if="hero" class="m-0 tabular-nums bold pb-025 txt-1-25rem">{{ hero }}</p>
@@ -119,12 +154,12 @@ watch(
119
154
 
120
155
  <style scoped>
121
156
  .bgl-tip {
122
- transform: translateX(-50%); transform-origin: center;
157
+ transform: translateX(calc(-50% + var(--bgl-tip-shift-x, 0px))); transform-origin: center;
123
158
  background: var(--bgl-black); border-radius: 0.5rem; padding: 0.4rem 0.6rem;
124
159
  font-size: 0.75rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); min-width: 7rem;
125
160
  }
126
- .bgl-tip--above { transform: translate(-50%, calc(-100% - 0.6rem)); }
127
- .bgl-tip--below { transform: translate(-50%, 0.6rem); }
161
+ .bgl-tip--above { transform: translate(calc(-50% + var(--bgl-tip-shift-x, 0px)), calc(-100% - 0.6rem)); }
162
+ .bgl-tip--below { transform: translate(calc(-50% + var(--bgl-tip-shift-x, 0px)), 0.6rem); }
128
163
  .bgl-tip__dot { width: 8px; height: 8px; border-radius: 2px; }
129
164
  .bgl-tip__name { opacity: 0.7; }
130
165
  .bgl-tip__total,
@@ -180,7 +180,7 @@ const headerFontSize = computed(() => {
180
180
 
181
181
  <template>
182
182
  <div class="table-list-wrap h-100p" v-bind="containerProps" :class="{ overflowHiddenLoading: showLoading }">
183
- <Loading v-if="showLoading" class="h100p" />
183
+ <Loading v-if="showLoading" class="absolute inset-1" />
184
184
  <div v-if="!isEmpty" v-bind="wrapperProps" :class="{ 'pointer-events-none': showLoading }">
185
185
  <table class="infinite-wrapper" :class="{ selecting: computedSelectedItems.length }">
186
186
  <thead class="row first-row">
@@ -0,0 +1,41 @@
1
+ import type { AxiosResponse } from 'axios'
2
+ import type { FileAttachment } from './upload.types'
3
+ import { getUploadApi } from './upload'
4
+
5
+ export interface AttachRequest {
6
+ file_id: string
7
+ record_id: string
8
+ record_type: string
9
+ slot?: string
10
+ label?: string | null
11
+ sort_order?: number
12
+ }
13
+
14
+ export interface AttachManyRequest {
15
+ file_ids: string[]
16
+ record_id: string
17
+ record_type: string
18
+ slot?: string
19
+ }
20
+
21
+ export const attachments = {
22
+ list: async (params: {
23
+ record_id: string
24
+ record_type?: string
25
+ slot?: string
26
+ }): Promise<AxiosResponse<FileAttachment[]>> => {
27
+ return getUploadApi().get('/files/attachments', { params })
28
+ },
29
+
30
+ attach: async (body: AttachRequest): Promise<AxiosResponse<FileAttachment>> => {
31
+ return getUploadApi().post('/files/attachments', body)
32
+ },
33
+
34
+ attachMany: async (body: AttachManyRequest): Promise<AxiosResponse<FileAttachment[]>> => {
35
+ return getUploadApi().post('/files/attachments/batch', body)
36
+ },
37
+
38
+ detach: async (attachmentId: string): Promise<AxiosResponse<void>> => {
39
+ return getUploadApi().delete(`/files/attachments/${attachmentId}`)
40
+ },
41
+ }
@@ -10,24 +10,33 @@ export function createUploadApi(options: { baseURL?: string } = {}) {
10
10
  }
11
11
  }
12
12
 
13
+ /** @internal — shared with attachments.ts */
14
+ export function getUploadApi(): AxiosInstance {
15
+ return api
16
+ }
17
+
13
18
  export const files = {
14
- get: async (pathKey?: string): Promise<AxiosResponse<BglFile>> => {
15
- return api.get('/files', { params: { path_key: pathKey } })
19
+ get: async (pathKey: string): Promise<AxiosResponse<BglFile>> => {
20
+ return api.get(`/files/${pathKey}`)
21
+ },
22
+
23
+ patch: async (
24
+ pathKey: string,
25
+ bglFilePatch: BglFilePatch,
26
+ ): Promise<AxiosResponse<BglFile>> => {
27
+ return api.patch(`/files/${pathKey}`, bglFilePatch)
16
28
  },
17
29
 
30
+ /** @deprecated use patch() */
18
31
  put: async (
19
32
  bglFilePatch: BglFilePatch,
20
33
  pathKey?: string
21
34
  ): Promise<AxiosResponse<BglFile>> => {
22
- return api.put('/files', bglFilePatch, {
23
- params: { path_key: pathKey },
24
- })
35
+ return api.patch(`/files/${pathKey}`, bglFilePatch)
25
36
  },
26
37
 
27
- delete: async (pathKey?: string) => {
28
- return api.delete(`/files/${pathKey}`, {
29
- params: { path_key: pathKey },
30
- })
38
+ delete: async (pathKey: string) => {
39
+ return api.delete(`/files/${pathKey}`)
31
40
  },
32
41
 
33
42
  upload: async (
@@ -46,7 +55,7 @@ export const files = {
46
55
  },
47
56
 
48
57
  list: async (dirPath?: string): Promise<AxiosResponse<BglFile[]>> => {
49
- return api.get('/files/list', {
58
+ return api.get('/files', {
50
59
  params: { dir_path: dirPath },
51
60
  })
52
61
  },
@@ -43,3 +43,21 @@ export interface QueueFile {
43
43
  uploaded?: boolean
44
44
  namespace?: string
45
45
  }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Attachments
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface FileAttachment {
52
+ id: string
53
+ file_id: string
54
+ record_id: string
55
+ record_type: string
56
+ slot: string
57
+ label?: string | null
58
+ sort_order: number
59
+ created_by?: string | null
60
+ created_at?: string
61
+ updated_at?: string
62
+ file?: BglFile | null
63
+ }
@@ -0,0 +1,175 @@
1
+ import type { MaybeRefOrGetter } from 'vue'
2
+ import type { FileAttachment } from './upload.types'
3
+ import { ref, watch, toValue } from 'vue'
4
+ import { attachments } from './attachments'
5
+ import { files } from './upload'
6
+
7
+ export interface UseAttachmentsProps {
8
+ /** UUID of the domain record */
9
+ recordId: MaybeRefOrGetter<string | undefined>
10
+ /** Polymorphic type, e.g. "deal_product", "person" */
11
+ recordType: MaybeRefOrGetter<string>
12
+ /** Semantic slot filter, e.g. "image", "certificate" */
13
+ slot?: MaybeRefOrGetter<string | undefined>
14
+ /** Allow multiple attachments (default true) */
15
+ multiple?: boolean
16
+ /** Auto-load on mount / when recordId changes */
17
+ autoLoad?: boolean
18
+ /** Accepted file types for browse() */
19
+ accept?: string
20
+ /** Callback after an attachment is added or removed */
21
+ onChange?: () => void
22
+ }
23
+
24
+ export function useAttachments(props: UseAttachmentsProps) {
25
+ const items = ref<FileAttachment[]>([])
26
+ const loading = ref(false)
27
+ const uploading = ref(false)
28
+
29
+ // -----------------------------------------------------------------------
30
+ // Load
31
+ // -----------------------------------------------------------------------
32
+
33
+ async function load() {
34
+ const rid = toValue(props.recordId)
35
+ if (!rid) { items.value = []; return }
36
+
37
+ loading.value = true
38
+ try {
39
+ const params: { record_id: string; record_type?: string; slot?: string } = {
40
+ record_id: rid,
41
+ record_type: toValue(props.recordType),
42
+ }
43
+ const slot = toValue(props.slot)
44
+ if (slot) { params.slot = slot }
45
+
46
+ const { data } = await attachments.list(params)
47
+ items.value = data
48
+ } catch (e) {
49
+ console.error('[useAttachments] load failed', e)
50
+ } finally {
51
+ loading.value = false
52
+ }
53
+ }
54
+
55
+ // Auto-reload when recordId changes
56
+ if (props.autoLoad !== false) {
57
+ watch(() => toValue(props.recordId), load, { immediate: true })
58
+ }
59
+
60
+ // -----------------------------------------------------------------------
61
+ // Attach
62
+ // -----------------------------------------------------------------------
63
+
64
+ /** Attach an already-uploaded file by its path_key */
65
+ async function attach(fileId: string, opts?: { label?: string; sortOrder?: number }) {
66
+ const rid = toValue(props.recordId)
67
+ if (!rid) { return }
68
+
69
+ const { data } = await attachments.attach({
70
+ file_id: fileId,
71
+ record_id: rid,
72
+ record_type: toValue(props.recordType),
73
+ slot: toValue(props.slot) || 'file',
74
+ label: opts?.label,
75
+ sort_order: opts?.sortOrder,
76
+ })
77
+
78
+ if (props.multiple !== false) {
79
+ items.value.push(data)
80
+ } else {
81
+ items.value = [data]
82
+ }
83
+ props.onChange?.()
84
+ return data
85
+ }
86
+
87
+ // -----------------------------------------------------------------------
88
+ // Upload + Attach in one shot
89
+ // -----------------------------------------------------------------------
90
+
91
+ async function uploadAndAttach(
92
+ file: File,
93
+ opts?: { label?: string; dirPath?: string; tags?: string[]; onProgress?: (pct: number) => void }
94
+ ) {
95
+ const rid = toValue(props.recordId)
96
+ if (!rid) { return }
97
+
98
+ uploading.value = true
99
+ try {
100
+ // 1. Upload
101
+ const { data: uploaded } = await files.upload(file, {
102
+ dirPath: opts?.dirPath,
103
+ tags: opts?.tags,
104
+ onUploadProgress: (e: ProgressEvent) => {
105
+ opts?.onProgress?.((e.loaded / e.total) * 100)
106
+ },
107
+ })
108
+
109
+ // 2. Attach
110
+ return await attach(uploaded.path_key, { label: opts?.label })
111
+ } finally {
112
+ uploading.value = false
113
+ }
114
+ }
115
+
116
+ // -----------------------------------------------------------------------
117
+ // Detach
118
+ // -----------------------------------------------------------------------
119
+
120
+ async function detach(attachmentId: string) {
121
+ await attachments.detach(attachmentId)
122
+ items.value = items.value.filter(a => a.id !== attachmentId)
123
+ props.onChange?.()
124
+ }
125
+
126
+ /** Detach all items (e.g. before replacing in single mode) */
127
+ async function detachAll() {
128
+ await Promise.all(items.value.map(a => attachments.detach(a.id)))
129
+ items.value = []
130
+ props.onChange?.()
131
+ }
132
+
133
+ // -----------------------------------------------------------------------
134
+ // Browse (open file picker → upload → attach)
135
+ // -----------------------------------------------------------------------
136
+
137
+ function browse(opts?: { label?: string; dirPath?: string; tags?: string[] }) {
138
+ const input = document.createElement('input')
139
+ input.type = 'file'
140
+ input.multiple = props.multiple !== false
141
+ input.accept = props.accept || ''
142
+
143
+ input.onchange = async (e: Event) => {
144
+ const picked = (e.target as HTMLInputElement).files
145
+ if (!picked) { return }
146
+
147
+ for (const file of Array.from(picked)) {
148
+ await uploadAndAttach(file, opts)
149
+ }
150
+ }
151
+
152
+ input.click()
153
+ }
154
+
155
+ return {
156
+ /** Reactive list of current attachments */
157
+ items,
158
+ /** True while loading attachments from server */
159
+ loading,
160
+ /** True while a file is being uploaded */
161
+ uploading,
162
+ /** Reload attachments from server */
163
+ load,
164
+ /** Attach an already-uploaded file */
165
+ attach,
166
+ /** Upload a file then attach it */
167
+ uploadAndAttach,
168
+ /** Remove an attachment (keeps the file) */
169
+ detach,
170
+ /** Remove all attachments */
171
+ detachAll,
172
+ /** Open file picker → upload → attach */
173
+ browse,
174
+ }
175
+ }
@@ -25,3 +25,5 @@ export { createUploadApi } from './Upload/upload'
25
25
 
26
26
  export { default as UploadInput } from './Upload/UploadInput.vue'
27
27
  export { useFileUpload } from './Upload/useFileUpload'
28
+ export { useAttachments } from './Upload/useAttachments'
29
+ export type { FileAttachment } from './Upload/upload.types'