@globalbrain/sefirot 4.52.1 → 4.54.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.
@@ -70,9 +70,23 @@ export interface AvatarFieldData extends FieldDataBase {
70
70
  * avatar uses the name (resolved against the active language) for the
71
71
  * initials fallback / tooltip when the image is unavailable. The field
72
72
  * value itself is the image URL.
73
+ *
74
+ * When the field is editable, these companion keys also become editable from
75
+ * the inline cell (the pencil affordance beside the avatar edits both names),
76
+ * so the avatar and its names can be updated together.
73
77
  */
74
78
  nameEn?: string | null
75
79
  nameJa?: string | null
80
+ /**
81
+ * The `accept` attribute for the file picker (e.g. `image/jpeg,image/png`).
82
+ * Defaults to `image/*`.
83
+ */
84
+ accept?: string | null
85
+ /** Help text shown beneath the picker, per language. */
86
+ helpEn?: string | null
87
+ helpJa?: string | null
88
+ /** Picker preview shape. Defaults to `circle` (avatars are round). */
89
+ imageType?: 'circle' | 'rectangle' | null
76
90
  }
77
91
 
78
92
  export interface BooleanFieldData extends FieldDataBase {
@@ -20,6 +20,8 @@ export type Rule =
20
20
  | BeforeOrEqualRule
21
21
  | AfterRule
22
22
  | AfterOrEqualRule
23
+ | FileExtensionRule
24
+ | MaxFileSizeRule
23
25
  | EachRule
24
26
 
25
27
  export interface MaxLengthRule {
@@ -115,6 +117,16 @@ export interface AfterOrEqualRule {
115
117
  date: string
116
118
  }
117
119
 
120
+ export interface FileExtensionRule {
121
+ type: 'file_extension'
122
+ extensions: string[]
123
+ }
124
+
125
+ export interface MaxFileSizeRule {
126
+ type: 'max_file_size'
127
+ size: string
128
+ }
129
+
118
130
  /**
119
131
  * Applies the nested `rules` to every element of an array value, mirroring
120
132
  * Laravel's `field.*` wildcard on the backend.
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import SInputImage, { type ImageType, type Size } from '../../../components/SInputImage.vue'
3
+ import { useTrans } from '../../../composables/Lang'
4
+ import { type Validatable } from '../../../composables/Validation'
5
+
6
+ withDefaults(defineProps<{
7
+ label?: string
8
+ help?: string
9
+ accept?: string
10
+ imageType?: ImageType
11
+ size?: Size
12
+ disabled?: boolean
13
+ validation?: Validatable
14
+ }>(), {
15
+ imageType: 'circle',
16
+ size: 'small'
17
+ })
18
+
19
+ // The model is the raw `File` the user just picked, the existing image URL
20
+ // (a string), or `null` when there is no image / it was removed. `SInputImage`
21
+ // renders a preview from any of these via `useImageSrcFromFile`.
22
+ const model = defineModel<File | string | null>()
23
+
24
+ const { t } = useTrans({
25
+ en: { select: 'Select image', remove: 'Remove image' },
26
+ ja: { select: '画像を選択', remove: '画像を削除' }
27
+ })
28
+ </script>
29
+
30
+ <template>
31
+ <SInputImage
32
+ v-model="model"
33
+ class="LensAvatarInput"
34
+ :label
35
+ :help
36
+ :accept
37
+ :image-type
38
+ :size
39
+ :disabled
40
+ :select-text="t.select"
41
+ :remove-text="t.remove"
42
+ :validation
43
+ />
44
+ </template>
@@ -839,7 +839,7 @@ function resolveLabel(record: Record<string, any>): string | null {
839
839
  }
840
840
 
841
841
  const { execute: executeUpdate } = useMutation((http, id: any, values: Record<string, any>) =>
842
- http.post(`${endpointBase.value}/update`, {
842
+ http.post<LensResult & { data: Record<string, any> }>(`${endpointBase.value}/update`, {
843
843
  entity: entityName.value, id, values, settings: { lang }
844
844
  })
845
845
  )
@@ -1196,6 +1196,103 @@ function save(record: Record<string, any>, values: Record<string, any>): void {
1196
1196
  trackWrite(id, key, 'save', resolveLabel(record), () => gate.then(() => executeUpdate(id, values)))
1197
1197
  }
1198
1198
 
1199
+ // Reflect a change on the in-memory record. Pure in-memory mutation: the caller
1200
+ // (`saveBlocking`) owns the write accounting (invalidation, reconcile). The
1201
+ // catalog row and any open sheet share the object, so the change shows
1202
+ // immediately. The row identifier is stripped so it can never re-key.
1203
+ function patch(record: Record<string, any>, values: Record<string, any>): void {
1204
+ const indexField = idField.value
1205
+ if (indexField in values) {
1206
+ values = { ...values }
1207
+ delete values[indexField]
1208
+ }
1209
+ if (Object.keys(values).length === 0) { return }
1210
+ Object.assign(record, values)
1211
+ }
1212
+
1213
+ // Update — blocking. For a field whose input value can't be shown optimistically
1214
+ // (a file input holds a raw `File`, not the displayed URL), the write must finish
1215
+ // before the row can reflect it. Awaits the Lens update (sent multipart when a
1216
+ // `File` is present) and patches the submitted columns from the canonical
1217
+ // response, with the same accounting as an optimistic write so it stays
1218
+ // consistent with concurrent reads/writes:
1219
+ // - the invalidation preamble (mirroring `trackWrite`) supersedes any stashed /
1220
+ // in-flight reconcile or search, so a request that predates the write can't
1221
+ // reassign stale rows mid-flight;
1222
+ // - `pendingWrites` locks the query controls while it's in flight, and
1223
+ // `pendingByRecord` arms `openSheet`'s `/show` stale-read guard;
1224
+ // - writes to the same record+columns serialize on `writeChains` (last issued
1225
+ // wins, and a delete gates on them);
1226
+ // - only the submitted columns are patched — not the whole row, so a concurrent
1227
+ // optimistic edit to another column isn't reverted — onto the live displayed
1228
+ // row (resolved by id), not only the passed `record` (an orphan after a
1229
+ // create refetched);
1230
+ // - a reconcile resyncs once the batch settles.
1231
+ // Rejects if the write fails: nothing was patched, so the row keeps its previous
1232
+ // value and the caller need only surface the error.
1233
+ async function saveBlocking(record: Record<string, any>, values: Record<string, any>): Promise<void> {
1234
+ const id = resolveId(record)
1235
+ // Never let an update re-key the row (mirrors save()).
1236
+ const indexField = idField.value
1237
+ if (indexField in values) {
1238
+ values = { ...values }
1239
+ delete values[indexField]
1240
+ }
1241
+ if (Object.keys(values).length === 0) { return }
1242
+
1243
+ latestState.value = null
1244
+ reconcileToken++
1245
+ searchToken++
1246
+ reconciling.value = false
1247
+ pendingWrites.value++
1248
+ pendingByRecord.set(id, (pendingByRecord.get(id) ?? 0) + 1)
1249
+
1250
+ // Serialize writes to the same record+columns: chain after any in-flight one so
1251
+ // its request is sent (and patched) only once the earlier resolves. Keyed like
1252
+ // save() so the delete gate (which scans writeChains) waits for it too.
1253
+ const key = `${id}:${Object.keys(values).sort().join(',')}`
1254
+ const prev = writeChains.get(key) ?? Promise.resolve()
1255
+ const run = prev.catch(() => {}).then(() => executeUpdate(id, values))
1256
+ writeChains.set(key, run)
1257
+
1258
+ try {
1259
+ const res = await run
1260
+ // Patch only the submitted columns the canonical row actually carries — a
1261
+ // withheld (write-only) response mustn't null a column we can't read back —
1262
+ // plus the server timestamp (falling back to ~now) like save(); the reconcile
1263
+ // ignores updated_at, so it never surfaces the banner just for this.
1264
+ const data = res.data ?? {}
1265
+ const patched: Record<string, any> = {}
1266
+ for (const k of Object.keys(values)) {
1267
+ if (k in data) { patched[k] = data[k] }
1268
+ }
1269
+ if (UPDATED_AT_KEY in record) {
1270
+ patched[UPDATED_AT_KEY] = data[UPDATED_AT_KEY] ?? day().toISOString()
1271
+ }
1272
+ patch(record, patched)
1273
+ const row = result.value?.data.find((r) => resolveId(r) === id)
1274
+ if (row && row !== record) {
1275
+ patch(row, patched)
1276
+ }
1277
+ } finally {
1278
+ if (writeChains.get(key) === run) {
1279
+ writeChains.delete(key)
1280
+ }
1281
+ const remaining = (pendingByRecord.get(id) ?? 1) - 1
1282
+ remaining > 0 ? pendingByRecord.set(id, remaining) : pendingByRecord.delete(id)
1283
+ pendingWrites.value--
1284
+ if (pendingWrites.value === 0) {
1285
+ // Drive the post-batch reconcile like trackWrite does — this write shares
1286
+ // the `pendingWrites` batch, so when it settles last it must forward (and
1287
+ // reset) `batchErrored` so a concurrent failed write's diverged row still
1288
+ // gets the recovery banner, and the flag doesn't leak into the next batch.
1289
+ const errored = batchErrored
1290
+ batchErrored = false
1291
+ reconcile(errored)
1292
+ }
1293
+ }
1294
+ }
1295
+
1199
1296
  // Create — blocking. The slow POST runs to completion, then the catalog is
1200
1297
  // refreshed with the now-synced canonical state (preserving the active query).
1201
1298
  // The create sheet shows a button spinner meanwhile.
@@ -1290,7 +1387,9 @@ function remove(record: Record<string, any>): void {
1290
1387
  .filter(([key]) => key.startsWith(`${id}:`))
1291
1388
  .map(([, chain]) => chain)
1292
1389
  // `Promise.allSettled([])` resolves immediately, so this is a no-op gate when
1293
- // the record has no in-flight writes.
1390
+ // the record has no in-flight writes. Blocking avatar writes share writeChains,
1391
+ // so they're gated too — a delete can't race an in-flight avatar write, which
1392
+ // would otherwise fail against a removed row or write after the delete.
1294
1393
  const gate = Promise.allSettled(pendingForRecord)
1295
1394
  trackWrite(id, `${id}:__delete__`, 'delete', resolveLabel(record), () => gate.then(() => executeDelete(id)))
1296
1395
  }
@@ -1407,10 +1506,12 @@ provideLensEdit({
1407
1506
  canDelete,
1408
1507
  resolveId,
1409
1508
  save,
1509
+ saveBlocking,
1410
1510
  create,
1411
1511
  remove,
1412
1512
  openSheet,
1413
- openCreate
1513
+ openCreate,
1514
+ refresh: refreshCatalog
1414
1515
  })
1415
1516
 
1416
1517
  // Warn the user (native prompt) if they try to leave while a write is still
@@ -39,9 +39,17 @@ const orderTextDict = {
39
39
  }
40
40
 
41
41
  function getFieldName(sort: LensQuerySort): string {
42
- return lang === 'ja'
43
- ? props.fields[sort[0]].labelJa
44
- : props.fields[sort[0]].labelEn
42
+ // A still-applied sort can reference a field that's not in the field set — a
43
+ // stale saved/URL sort, or a sort emitted under a companion key (e.g. an
44
+ // avatar sorting by its display-name field). Fall back to the raw key rather
45
+ // than crash, mirroring how the filter chip handles an unknown field.
46
+ const field = props.fields[sort[0]]
47
+
48
+ if (!field) {
49
+ return sort[0]
50
+ }
51
+
52
+ return lang === 'ja' ? field.labelJa : field.labelEn
45
53
  }
46
54
 
47
55
  function getOrderText(sort: LensQuerySort): string {
@@ -87,6 +87,8 @@ const selectOptions = ref(createSelectOptions())
87
87
 
88
88
  const selectedOption = ref<SelectOption | null>(null)
89
89
 
90
+ const hasSelection = computed(() => selectOptions.value.some((s) => s.value))
91
+
90
92
  useDraggable(el, selectOptions, {
91
93
  handle: '.handle'
92
94
  })
@@ -262,6 +264,7 @@ async function onApply() {
262
264
  size="md"
263
265
  mode="info"
264
266
  :label="t.a_apply"
267
+ :disabled="!hasSelection"
265
268
  @click="onApply"
266
269
  />
267
270
  </SCardFooter>
@@ -14,6 +14,7 @@ import { type FieldData } from '../FieldData'
14
14
  import { useFieldFactory } from '../composables/FieldFactory'
15
15
  import { useLensEdit } from '../composables/LensEdit'
16
16
  import { extractServerErrors, extractServerMessage } from '../validation/ServerErrors'
17
+ import LensSheetAvatarField from './LensSheetAvatarField.vue'
17
18
  import LensSheetField from './LensSheetField.vue'
18
19
 
19
20
  const props = withDefaults(defineProps<{
@@ -97,7 +98,9 @@ const createFieldViews = computed(() =>
97
98
  // The subset of create views that actually contribute a value. Display-only
98
99
  // fields (e.g. a `content` field showing instructions) still render, but
99
100
  // `formInputComponent()` returns static markup with no value, so they must not
100
- // be seeded, validated, or submitted.
101
+ // be seeded, validated, or submitted. An avatar field is submittable (its `File`
102
+ // rides the create payload as a multipart part), so it's seeded and validated
103
+ // here like any other input.
101
104
  const createInputViews = computed(() =>
102
105
  createFieldViews.value.filter((v) => v.field.isSubmittable())
103
106
  )
@@ -201,6 +204,8 @@ async function onCreate() {
201
204
  for (const { key, field } of createInputViews.value) {
202
205
  values[key] = field.inputToPayload(createModel[key])
203
206
  }
207
+ // A picked avatar `File` rides `values`; `edit.create` sends the payload
208
+ // multipart when one is present, so the avatar persists with the new record.
204
209
  await edit!.create(values)
205
210
  emit('close')
206
211
  } catch (e) {
@@ -327,13 +332,20 @@ const slotProps = computed(() => ({
327
332
  {{ t.load_error }}
328
333
  </div>
329
334
  <div v-else class="sheet-rows">
330
- <LensSheetField
331
- v-for="entry in detailFields"
332
- :key="entry.key"
333
- :field="entry.field"
334
- :field-key="entry.key"
335
- :record
336
- />
335
+ <template v-for="entry in detailFields" :key="entry.key">
336
+ <LensSheetAvatarField
337
+ v-if="entry.fieldData.type === 'avatar'"
338
+ :field="entry.field"
339
+ :field-key="entry.key"
340
+ :record
341
+ />
342
+ <LensSheetField
343
+ v-else
344
+ :field="entry.field"
345
+ :field-key="entry.key"
346
+ :record
347
+ />
348
+ </template>
337
349
  </div>
338
350
  </template>
339
351
 
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ import { computed, reactive, ref, watch } from 'vue'
3
+ import SDataListItem from '../../../components/SDataListItem.vue'
4
+ import { useTrans } from '../../../composables/Lang'
5
+ import { useValidation } from '../../../composables/Validation'
6
+ import { useSnackbars } from '../../../stores/Snackbars'
7
+ import { type FieldData } from '../FieldData'
8
+ import { useLensEdit } from '../composables/LensEdit'
9
+ import { type AvatarField } from '../fields/AvatarField'
10
+ import { type Field } from '../fields/Field'
11
+ import LensAvatarInput from './LensAvatarInput.vue'
12
+
13
+ const props = defineProps<{
14
+ field: Field<FieldData>
15
+ fieldKey: string
16
+ record: Record<string, any>
17
+ }>()
18
+
19
+ // The table only renders this component for `avatar` fields, so the field is
20
+ // always an `AvatarField` — narrow it for the avatar-specific config getters.
21
+ const avatarField = computed(() => props.field as AvatarField)
22
+
23
+ const { t } = useTrans({
24
+ en: { save_error: 'We couldn’t save the image. Please try again.' },
25
+ ja: { save_error: '画像を保存できませんでした。もう一度お試しください。' }
26
+ })
27
+
28
+ const edit = useLensEdit()
29
+ const snackbars = useSnackbars()
30
+
31
+ // Editable only when the row passes the edit gate and the field is marked
32
+ // editable; the image is written through the Lens update (a blocking save).
33
+ const editable = computed(() =>
34
+ !!edit?.canEdit(props.record)
35
+ && (props.field as any).data?.showOnUpdate === true
36
+ )
37
+
38
+ // Materialize the read-only display component once (computed off `props.field`):
39
+ // `dataListItemComponent()` mints a fresh definition each call, so resolving it
40
+ // inline in the template would unmount/remount the avatar on every reactive row
41
+ // update (e.g. a write's `patch`), causing flicker.
42
+ const displayComponent = computed(() => props.field.dataListItemComponent())
43
+
44
+ const saving = ref(false)
45
+
46
+ // The value the picker shows: the row's current image URL, except while a save
47
+ // is in flight (then the local file preview the change handler set).
48
+ const model = ref<File | string | null>(props.record[props.fieldKey] ?? null)
49
+
50
+ watch(
51
+ () => props.record[props.fieldKey],
52
+ (value) => { if (!saving.value) { model.value = value ?? null } }
53
+ )
54
+
55
+ // Validate the picked value (a File, or null on removal) against the field rules
56
+ // before the blocking save. The model usually holds the existing image URL — a
57
+ // string the file rules reject — so validation runs on what's submitted, here,
58
+ // rather than inline on the input (which would flag the unchanged URL).
59
+ const pending = reactive<{ value: File | null }>({ value: null })
60
+ const { validation, validate } = useValidation(
61
+ () => pending,
62
+ () => ({ value: avatarField.value.generateValidationRules() })
63
+ )
64
+
65
+ async function onChange(value: File | string | null | undefined) {
66
+ // A string is the existing URL still selected — nothing to save.
67
+ if (typeof value === 'string') {
68
+ model.value = value
69
+ return
70
+ }
71
+ const file = value ?? null
72
+ // Capture the record being edited: the sheet can be closed and reopened on
73
+ // another row while this save is in flight (only the input is disabled, not
74
+ // the sheet), so `props.record` may point elsewhere by the time we resolve.
75
+ const record = props.record
76
+ // Show the picked file (or the cleared empty state) immediately.
77
+ model.value = file
78
+
79
+ pending.value = file
80
+ if (!(await validate())) {
81
+ const message = validation.value.$errors[0]?.$message
82
+ snackbars.push({ mode: 'danger', text: message ? String(message) : t.save_error })
83
+ if (props.record === record) {
84
+ model.value = record[props.fieldKey] ?? null
85
+ }
86
+ return
87
+ }
88
+
89
+ saving.value = true
90
+ try {
91
+ // The catalog writes through the Lens update and patches the captured
92
+ // record's column itself (with write accounting); reflect the canonical
93
+ // value on this picker if it still shows the same record.
94
+ await edit?.saveBlocking(record, { [props.fieldKey]: file })
95
+ if (props.record === record) {
96
+ model.value = record[props.fieldKey] ?? null
97
+ }
98
+ } catch {
99
+ if (props.record === record) {
100
+ model.value = record[props.fieldKey] ?? null
101
+ }
102
+ snackbars.push({ mode: 'danger', text: t.save_error })
103
+ } finally {
104
+ saving.value = false
105
+ // If the sheet rebound to another row mid-save, resync the picker to it
106
+ // (the `props.record` watcher skipped re-seeding while `saving` was true).
107
+ if (props.record !== record) {
108
+ model.value = props.record[props.fieldKey] ?? null
109
+ }
110
+ }
111
+ }
112
+ </script>
113
+
114
+ <template>
115
+ <div class="LensSheetAvatarField">
116
+ <SDataListItem v-if="editable">
117
+ <template #label>{{ field.label() }}</template>
118
+ <template #value>
119
+ <LensAvatarInput
120
+ :model-value="model"
121
+ :accept="avatarField.accept()"
122
+ :image-type="avatarField.imageType()"
123
+ :help="field.help() || undefined"
124
+ :disabled="saving"
125
+ @update:model-value="onChange"
126
+ />
127
+ </template>
128
+ </SDataListItem>
129
+ <component :is="displayComponent" v-else :value="record[fieldKey]" />
130
+ </div>
131
+ </template>
132
+
133
+ <style scoped lang="postcss">
134
+ .LensSheetAvatarField {
135
+ position: relative;
136
+ border-bottom: 1px dashed var(--c-divider);
137
+ }
138
+ </style>
@@ -5,13 +5,14 @@ import { computed, markRaw } from 'vue'
5
5
  import STable from '../../../components/STable.vue'
6
6
  import { type DropdownSection } from '../../../composables/Dropdown'
7
7
  import { type TableCell, type TableColumns, useTable } from '../../../composables/Table'
8
- import { type FieldData } from '../FieldData'
8
+ import { type AvatarFieldData, type FieldData } from '../FieldData'
9
9
  import { type LensQuerySort } from '../LensQuery'
10
10
  import { type LensResult } from '../LensResult'
11
11
  import { useFieldFactory } from '../composables/FieldFactory'
12
12
  import { useLensEdit } from '../composables/LensEdit'
13
13
  import { provideLensInlineEdit } from '../composables/LensInlineEdit'
14
14
  import { type Field } from '../fields/Field'
15
+ import LensTableAvatarCell from './LensTableAvatarCell.vue'
15
16
  import LensTableEditableCell from './LensTableEditableCell.vue'
16
17
 
17
18
  const props = defineProps<{
@@ -44,6 +45,7 @@ const edit = useLensEdit()
44
45
  provideLensInlineEdit()
45
46
 
46
47
  const editableCellComponent = markRaw(LensTableEditableCell)
48
+ const avatarCellComponent = markRaw(LensTableAvatarCell)
47
49
 
48
50
  const records = computed(() => props.result?.data ?? [])
49
51
 
@@ -146,6 +148,31 @@ const columns = computedAsync(async () => {
146
148
  onClick: () => edit.openSheet(r)
147
149
  }
148
150
  }
151
+ } else if (
152
+ props.inlineEditable
153
+ && edit?.editable
154
+ && key !== edit.indexField
155
+ && overriddenFieldData.type === 'avatar'
156
+ && overriddenFieldData.showOnUpdate === true
157
+ ) {
158
+ // Avatars get a dedicated cell: a hover overlay to change the image
159
+ // (written through a blocking Lens update) plus an optional pencil that
160
+ // edits the display-name companions inline (optimistic). The image and
161
+ // names are persisted differently, so the generic editable cell (a single
162
+ // optimistic field write) can't serve them.
163
+ const avatarData = overriddenFieldData as AvatarFieldData
164
+ column.cell = {
165
+ type: 'component',
166
+ component: avatarCellComponent,
167
+ props: {
168
+ field,
169
+ fieldKey: key,
170
+ nameEnKey: avatarData.nameEn ?? null,
171
+ nameJaKey: avatarData.nameJa ?? null,
172
+ nameFieldEn: makeEditableField(r, avatarData.nameEn),
173
+ nameFieldJa: makeEditableField(r, avatarData.nameJa)
174
+ }
175
+ }
149
176
  } else if (
150
177
  props.inlineEditable
151
178
  && edit?.editable
@@ -237,6 +264,29 @@ function hasFormInput(field: Field<FieldData>): boolean {
237
264
  return false
238
265
  }
239
266
  }
267
+
268
+ // Build a sibling field instance for inline editing from the current result's
269
+ // field set — but only when it's editable (`showOnUpdate`) and safe to edit the
270
+ // same way the generic inline cell requires: submittable, optimistically
271
+ // patchable, and with a real form input. Used by the avatar cell to edit its
272
+ // display-name companions; this omits a companion that is non-editable, display
273
+ // only (`content`), holds raw files (`file_upload`), or has no input (`id` /
274
+ // `slack_message`), so the name editor can't send an unsafe value or crash.
275
+ function makeEditableField(r: LensResult, key?: string | null): Field<FieldData> | null {
276
+ if (!key) {
277
+ return null
278
+ }
279
+ const fieldData = r.fields[key]
280
+ if (!fieldData || fieldData.showOnUpdate !== true) {
281
+ return null
282
+ }
283
+ const data = Object.assign(cloneDeep(fieldData), props.overrides?.[key] ?? {})
284
+ const field = fieldFactory.make(data)
285
+ if (!field.isSubmittable() || !field.supportsOptimisticUpdate() || !hasFormInput(field)) {
286
+ return null
287
+ }
288
+ return field
289
+ }
240
290
  </script>
241
291
 
242
292
  <template>
@@ -0,0 +1,442 @@
1
+ <script setup lang="ts">
2
+ import IconCamera from '~icons/ph/camera'
3
+ import IconPencilSimple from '~icons/ph/pencil-simple'
4
+ import { useElementBounding } from '@vueuse/core'
5
+ import { computed, nextTick, onUnmounted, reactive, ref, watch } from 'vue'
6
+ import SAvatar from '../../../components/SAvatar.vue'
7
+ import SButton from '../../../components/SButton.vue'
8
+ import SSpinner from '../../../components/SSpinner.vue'
9
+ import { useManualDropdownPosition } from '../../../composables/Dropdown'
10
+ import { useTrans } from '../../../composables/Lang'
11
+ import { useValidation } from '../../../composables/Validation'
12
+ import { useSnackbars } from '../../../stores/Snackbars'
13
+ import { type FieldData } from '../FieldData'
14
+ import { useLensEdit } from '../composables/LensEdit'
15
+ import { useLensInlineEdit } from '../composables/LensInlineEdit'
16
+ import { type AvatarField } from '../fields/AvatarField'
17
+ import { type Field } from '../fields/Field'
18
+
19
+ const props = defineProps<{
20
+ field: AvatarField
21
+ fieldKey: string
22
+ // The English / Japanese display-name companion fields, resolved by the table
23
+ // from the avatar field's `nameEn` / `nameJa` keys. Present only when the
24
+ // avatar declares them, so the names can be edited alongside the image.
25
+ nameEnKey?: string | null
26
+ nameJaKey?: string | null
27
+ nameFieldEn?: Field<FieldData> | null
28
+ nameFieldJa?: Field<FieldData> | null
29
+ // Injected by STable for a `component` cell: the cell value (image URL) and
30
+ // the full row record.
31
+ value: any
32
+ record: Record<string, any>
33
+ }>()
34
+
35
+ const { t } = useTrans({
36
+ en: { cancel: 'Cancel', save: 'Save', edit_image: 'Change image', edit_names: 'Edit name', upload_error: 'We couldn’t upload the image. Please try again.' },
37
+ ja: { cancel: 'キャンセル', save: '保存', edit_image: '画像を変更', edit_names: '名前を編集', upload_error: '画像をアップロードできませんでした。もう一度お試しください。' }
38
+ })
39
+
40
+ const edit = useLensEdit()
41
+ const inline = useLensInlineEdit()
42
+ const snackbars = useSnackbars()
43
+
44
+ const canEdit = computed(() => !!edit?.canEdit(props.record))
45
+
46
+ // The image is written through the Lens update (a blocking save); the overlay
47
+ // affordance is shown whenever the row is editable.
48
+ const canEditImage = computed(() => canEdit.value)
49
+
50
+ const nameEntries = computed(() => {
51
+ const entries: { key: string; field: Field<FieldData> }[] = []
52
+ const seen = new Set<string>()
53
+ const add = (key: string | null | undefined, field: Field<FieldData> | null | undefined): void => {
54
+ // Skip a companion that isn't fetched on this row — editing the visible name
55
+ // would otherwise send an empty value for the unfetched sibling and clear it
56
+ // (e.g. `loadSelectable` exposes `name_ja` in `fields` while the select omits
57
+ // it). De-duplicate too: both languages may point at one shared name column.
58
+ if (!key || !field || seen.has(key) || !(key in props.record)) {
59
+ return
60
+ }
61
+ seen.add(key)
62
+ entries.push({ key, field })
63
+ }
64
+ add(props.nameEnKey, props.nameFieldEn)
65
+ add(props.nameJaKey, props.nameFieldJa)
66
+ return entries
67
+ })
68
+
69
+ const canEditNames = computed(() => canEdit.value && nameEntries.value.length > 0)
70
+
71
+ // --- Display (mirrors STableCellAvatar) -------------------------------------
72
+
73
+ const base = computed<any>(() => props.field.tableCell(props.value, props.record))
74
+
75
+ // While an upload is in flight, show a local preview of the picked file so the
76
+ // new image appears immediately instead of waiting on the round-trip.
77
+ const previewSrc = ref<string | null>(null)
78
+ const displayImage = computed(() => previewSrc.value ?? base.value.image ?? null)
79
+ const displayName = computed(() => base.value.name || null)
80
+
81
+ // --- Image edit (file picker -> blocking Lens update) -----------------------
82
+
83
+ const fileInput = ref<HTMLInputElement | null>(null)
84
+ const uploading = ref(false)
85
+
86
+ // Validate the picked image against the field rules before the blocking save.
87
+ // The picked value is a raw `File` (or null), so the file rules apply directly.
88
+ const imagePending = reactive<{ value: File | null }>({ value: null })
89
+ const { validation: imageValidation, validate: validateImage } = useValidation(
90
+ () => imagePending,
91
+ () => ({ value: props.field.generateValidationRules() })
92
+ )
93
+
94
+ function pickImage() {
95
+ if (!canEditImage.value || uploading.value) {
96
+ return
97
+ }
98
+ fileInput.value?.click()
99
+ }
100
+
101
+ async function onFilePicked(event: Event) {
102
+ const input = event.target as HTMLInputElement
103
+ const file = (input.files ?? [])[0] ?? null
104
+ // Reset the input so picking the same file again still fires `change`.
105
+ input.value = ''
106
+ if (!file) {
107
+ return
108
+ }
109
+ await upload(file)
110
+ }
111
+
112
+ async function upload(file: File | null) {
113
+ if (!canEditImage.value) {
114
+ return
115
+ }
116
+ imagePending.value = file
117
+ if (!(await validateImage())) {
118
+ const message = imageValidation.value.$errors[0]?.$message
119
+ snackbars.push({ mode: 'danger', text: message ? String(message) : t.upload_error })
120
+ return
121
+ }
122
+ uploading.value = true
123
+ previewSrc.value = file ? URL.createObjectURL(file) : null
124
+ try {
125
+ // The catalog writes through the Lens update and patches the row's column
126
+ // itself, with write accounting so a concurrent query change can't land
127
+ // stale rows over it; the patched value is the URL, never the raw file.
128
+ await edit?.saveBlocking(props.record, { [props.fieldKey]: file })
129
+ } catch {
130
+ // saveBlocking rejects on a failed write; guard with a generic message so a
131
+ // failure never goes unreported.
132
+ snackbars.push({ mode: 'danger', text: t.upload_error })
133
+ } finally {
134
+ if (previewSrc.value) {
135
+ URL.revokeObjectURL(previewSrc.value)
136
+ previewSrc.value = null
137
+ }
138
+ uploading.value = false
139
+ }
140
+ }
141
+
142
+ // --- Name edit (floating editor anchored to the cell) -----------------------
143
+
144
+ const myKey = computed(() => `${edit?.resolveId(props.record)}:${props.fieldKey}`)
145
+ const editingNames = computed(() => inline?.activeKey.value === myKey.value)
146
+
147
+ const anchor = ref<HTMLElement | null>(null)
148
+ const editorEl = ref<HTMLElement | null>(null)
149
+ const bounds = useElementBounding(anchor)
150
+ const { inset, update } = useManualDropdownPosition(anchor)
151
+
152
+ const nameModel = reactive<Record<string, any>>({})
153
+
154
+ // Materialize the name inputs once so the editor doesn't mint a fresh component
155
+ // definition each render (which breaks Vue's patching).
156
+ const nameInputs = computed(() =>
157
+ nameEntries.value.map((entry) => ({
158
+ key: entry.key,
159
+ field: entry.field,
160
+ component: entry.field.formInputComponent()
161
+ }))
162
+ )
163
+
164
+ const { validation, validate, reset } = useValidation(
165
+ () => nameModel,
166
+ () => Object.fromEntries(nameEntries.value.map((e) => [e.key, e.field.generateValidationRules()]))
167
+ )
168
+
169
+ const editorStyle = computed(() => ({
170
+ ...inset.value,
171
+ width: `${Math.max(bounds.width.value, 280)}px`
172
+ }))
173
+
174
+ // If the backing record is replaced while the name editor is open (a refresh
175
+ // apply, a parent refresh, a requery), close it so a stale model can't be saved.
176
+ watch(
177
+ () => nameEntries.value.map((e) => props.record[e.key]),
178
+ () => { if (editingNames.value) { inline?.stop() } },
179
+ { deep: true }
180
+ )
181
+
182
+ // STable virtualization can unmount this row while scrolling; clear the shared
183
+ // active key on unmount so a remount doesn't reopen a blank editor.
184
+ onUnmounted(() => {
185
+ if (inline?.activeKey.value === myKey.value) {
186
+ inline.stop()
187
+ }
188
+ })
189
+
190
+ function startNames() {
191
+ if (!canEditNames.value) {
192
+ return
193
+ }
194
+ for (const entry of nameEntries.value) {
195
+ nameModel[entry.key] = entry.field.payloadToInput(props.record[entry.key] ?? entry.field.inputEmptyValue())
196
+ }
197
+ reset()
198
+ inline?.start(myKey.value)
199
+ nextTick(() => {
200
+ update()
201
+ const el = editorEl.value?.querySelector('input, textarea, [contenteditable], [tabindex]') as HTMLElement | null
202
+ el?.focus()
203
+ })
204
+ }
205
+
206
+ function cancelNames() {
207
+ inline?.stop()
208
+ }
209
+
210
+ async function applyNames() {
211
+ // Snapshot the companion values we're editing so we can detect the backing row
212
+ // being replaced (a refresh apply, a parent refresh, a requery, or a sibling
213
+ // save) during the async validate, and avoid clobbering the fresh state.
214
+ const edited = nameEntries.value.map((entry) => props.record[entry.key])
215
+
216
+ if (!(await validate())) {
217
+ return
218
+ }
219
+
220
+ // A refresh / requery can swap a companion value during the validate microtask;
221
+ // the watcher above closes the editor but flushes asynchronously and can't abort
222
+ // this running apply, so re-check against the snapshot. If any changed, `model`
223
+ // predates the fresh value — bail rather than overwrite it.
224
+ if (nameEntries.value.some((entry, i) => props.record[entry.key] !== edited[i])) {
225
+ if (inline?.activeKey.value === myKey.value) {
226
+ inline.stop()
227
+ }
228
+ return
229
+ }
230
+
231
+ // The editable predicate can flip to reject this row while the editor is open.
232
+ if (!canEditNames.value) {
233
+ if (inline?.activeKey.value === myKey.value) {
234
+ inline.stop()
235
+ }
236
+ return
237
+ }
238
+ const values: Record<string, any> = {}
239
+ for (const entry of nameEntries.value) {
240
+ values[entry.key] = entry.field.inputToPayload(nameModel[entry.key])
241
+ }
242
+ edit!.save(props.record, values)
243
+ if (inline?.activeKey.value === myKey.value) {
244
+ inline.stop()
245
+ }
246
+ }
247
+
248
+ function onEditorKeydown(event: KeyboardEvent) {
249
+ if (event.key === 'Escape') {
250
+ event.preventDefault()
251
+ cancelNames()
252
+ }
253
+ }
254
+ </script>
255
+
256
+ <template>
257
+ <div ref="anchor" class="LensTableAvatarCell" :class="{ editing: editingNames }">
258
+ <div v-if="displayImage || displayName || canEditImage" class="avatar">
259
+ <button
260
+ class="image"
261
+ type="button"
262
+ :class="{ editable: canEditImage }"
263
+ :disabled="!canEditImage || uploading"
264
+ :aria-label="`${t.edit_image} ${field.label()}`"
265
+ @click="pickImage"
266
+ >
267
+ <SAvatar size="mini" :avatar="displayImage" :name="displayName" />
268
+ <span v-if="canEditImage" class="image-overlay">
269
+ <SSpinner v-if="uploading" class="image-spinner" />
270
+ <IconCamera v-else class="image-icon" />
271
+ </span>
272
+ </button>
273
+ <span v-if="displayName" class="name">{{ displayName }}</span>
274
+ </div>
275
+
276
+ <input
277
+ v-if="canEditImage"
278
+ ref="fileInput"
279
+ class="file-input"
280
+ type="file"
281
+ :accept="field.accept()"
282
+ @change="onFilePicked"
283
+ >
284
+
285
+ <button
286
+ v-if="canEditNames"
287
+ class="edit"
288
+ type="button"
289
+ :aria-label="`${t.edit_names} ${field.label()}`"
290
+ @click="startNames"
291
+ >
292
+ <IconPencilSimple class="edit-icon" />
293
+ </button>
294
+
295
+ <Teleport v-if="editingNames" to="#sefirot-modals">
296
+ <div
297
+ ref="editorEl"
298
+ class="LensTableAvatarCellEditor"
299
+ :style="editorStyle"
300
+ @keydown="onEditorKeydown"
301
+ >
302
+ <component
303
+ :is="input.component"
304
+ v-for="input in nameInputs"
305
+ :key="input.key"
306
+ v-model="nameModel[input.key]"
307
+ :validation="validation[input.key]"
308
+ />
309
+ <div class="actions">
310
+ <SButton size="mini" :label="t.cancel" @click="cancelNames" />
311
+ <SButton size="mini" mode="info" :label="t.save" @click="applyNames" />
312
+ </div>
313
+ </div>
314
+ </Teleport>
315
+ </div>
316
+ </template>
317
+
318
+ <style scoped lang="postcss">
319
+ .LensTableAvatarCell {
320
+ position: relative;
321
+ display: flex;
322
+ align-items: center;
323
+ min-width: 0;
324
+ min-height: 40px;
325
+ padding: 8px 36px 8px 16px;
326
+ }
327
+
328
+ .avatar {
329
+ display: flex;
330
+ align-items: center;
331
+ min-width: 0;
332
+ }
333
+
334
+ .image {
335
+ position: relative;
336
+ display: flex;
337
+ flex-shrink: 0;
338
+ border-radius: 50%;
339
+ }
340
+
341
+ .image.editable {
342
+ cursor: pointer;
343
+ }
344
+
345
+ .image:disabled {
346
+ cursor: default;
347
+ }
348
+
349
+ .image-overlay {
350
+ position: absolute;
351
+ inset: 0;
352
+ display: flex;
353
+ align-items: center;
354
+ justify-content: center;
355
+ border-radius: 50%;
356
+ background-color: rgba(0, 0, 0, 0.5);
357
+ color: #fff;
358
+ opacity: 0;
359
+ transition: opacity 0.1s;
360
+ }
361
+
362
+ .image.editable:hover .image-overlay,
363
+ .image:disabled .image-overlay {
364
+ opacity: 1;
365
+ }
366
+
367
+ .image-icon {
368
+ width: 12px;
369
+ height: 12px;
370
+ }
371
+
372
+ .image-spinner {
373
+ width: 12px;
374
+ height: 12px;
375
+ }
376
+
377
+ .name {
378
+ display: inline-block;
379
+ margin-left: 8px;
380
+ line-height: 24px;
381
+ font-size: var(--table-cell-font-size);
382
+ font-weight: var(--table-cell-font-weight);
383
+ color: var(--c-text-1);
384
+ white-space: nowrap;
385
+ overflow: hidden;
386
+ text-overflow: ellipsis;
387
+ }
388
+
389
+ .file-input {
390
+ display: none;
391
+ }
392
+
393
+ .edit {
394
+ position: absolute;
395
+ top: 50%;
396
+ right: 8px;
397
+ display: flex;
398
+ align-items: center;
399
+ justify-content: center;
400
+ width: 24px;
401
+ height: 24px;
402
+ transform: translateY(-50%);
403
+ border-radius: 6px;
404
+ color: var(--c-text-2);
405
+ opacity: 0;
406
+ transition: opacity 0.1s, background-color 0.1s, color 0.1s;
407
+ }
408
+
409
+ .LensTableAvatarCell:hover .edit,
410
+ .LensTableAvatarCell.editing .edit {
411
+ opacity: 1;
412
+ }
413
+
414
+ .edit:hover {
415
+ background-color: var(--c-bg-mute-1);
416
+ color: var(--c-text-1);
417
+ }
418
+
419
+ .edit-icon {
420
+ width: 14px;
421
+ height: 14px;
422
+ }
423
+
424
+ .LensTableAvatarCellEditor {
425
+ position: fixed;
426
+ z-index: var(--z-index-sheet, 2000);
427
+ display: flex;
428
+ flex-direction: column;
429
+ gap: 8px;
430
+ padding: 8px;
431
+ border: 1px solid var(--c-border);
432
+ border-radius: 8px;
433
+ background-color: var(--c-bg-1);
434
+ box-shadow: var(--shadow-depth-3);
435
+ }
436
+
437
+ .actions {
438
+ display: flex;
439
+ justify-content: flex-end;
440
+ gap: 8px;
441
+ }
442
+ </style>
@@ -35,6 +35,21 @@ export interface LensEditContext {
35
35
  */
36
36
  save: (record: Record<string, any>, values: Record<string, any>) => void
37
37
 
38
+ /**
39
+ * Apply an update that can't be shown optimistically and wait for it. Some
40
+ * fields hold a value whose shape differs from what the row renders — a file
41
+ * input holds a raw `File`, not the displayed image URL — so the row can only
42
+ * reflect the change once the write returns the canonical value. Awaits the
43
+ * Lens update (sent multipart when a `File` is present) and patches the
44
+ * submitted columns from the response. Accounted for like an optimistic write
45
+ * (the query controls lock while it's in flight and a reconcile resyncs once it
46
+ * settles), so a concurrent refetch can't land stale rows over it. Patches the
47
+ * live displayed row (resolved by id) as well as the `record` passed at call
48
+ * time. Rejects if the write fails (the caller surfaces it and keeps the
49
+ * previous value).
50
+ */
51
+ saveBlocking: (record: Record<string, any>, values: Record<string, any>) => Promise<void>
52
+
38
53
  /**
39
54
  * Create a new record. Blocking: resolves once the record is persisted and
40
55
  * the catalog has been refreshed with the canonical state.
@@ -52,6 +67,13 @@ export interface LensEditContext {
52
67
 
53
68
  /** Open the record sheet in create mode. */
54
69
  openCreate: () => void
70
+
71
+ /**
72
+ * Re-run the current query against the server, preserving the catalog's
73
+ * in-memory state. Used to reflect an external change once it has settled.
74
+ * No-ops while a write or create is in flight.
75
+ */
76
+ refresh: () => Promise<void>
55
77
  }
56
78
 
57
79
  const LensEditKey: InjectionKey<LensEditContext> = Symbol('LensEdit')
@@ -3,6 +3,7 @@ import SAvatar from '../../../components/SAvatar.vue'
3
3
  import { type TableCell } from '../../../composables/Table'
4
4
  import { type AvatarFieldData } from '../FieldData'
5
5
  import { type FilterOperator } from '../FilterOperator'
6
+ import LensAvatarInput from '../components/LensAvatarInput.vue'
6
7
  import { type FilterInput } from '../filter-inputs/FilterInput'
7
8
  import { Field } from './Field'
8
9
 
@@ -10,7 +11,7 @@ export class AvatarField extends Field<AvatarFieldData> {
10
11
  override tableCell(v: any, r: any): TableCell {
11
12
  // The display name lives on a sibling key of the record (resolved by the
12
13
  // active language), not on the field value, which is the image URL.
13
- const nameKey = this.ctx.lang === 'ja' ? this.data.nameJa : this.data.nameEn
14
+ const nameKey = this.nameKey()
14
15
  return {
15
16
  type: 'avatar',
16
17
  // The field value is the image URL itself. A missing value renders an
@@ -28,6 +29,13 @@ export class AvatarField extends Field<AvatarFieldData> {
28
29
  return {}
29
30
  }
30
31
 
32
+ // Sort by the display-name companion (resolved by the active language), since
33
+ // the field value itself is the image URL. With no companion configured there's
34
+ // nothing meaningful to sort on, so the field stays unsortable (no sort menu).
35
+ protected override sortKey(): string | null {
36
+ return this.nameKey()
37
+ }
38
+
31
39
  override dataListItemComponent(): any {
32
40
  return this.defineDataListItemComponent((value) => {
33
41
  // Render nothing for a missing image so SDataListItem shows its empty
@@ -37,7 +45,54 @@ export class AvatarField extends Field<AvatarFieldData> {
37
45
  })
38
46
  }
39
47
 
48
+ // The avatar value is a raw `File` on edit (or null on removal), submitted
49
+ // through the Lens create/update write as a multipart part — so it stays
50
+ // submittable (the base default). On read it's the image URL, a different
51
+ // shape that can't be shown optimistically: the write must complete before the
52
+ // row can reflect the new URL. So editing routes through the dedicated avatar
53
+ // cell / sheet field (a blocking save), never the generic optimistic cell/field
54
+ // editors, which would patch the row with a raw `File` and crash the URL renderer.
55
+ override supportsOptimisticUpdate(): boolean {
56
+ return false
57
+ }
58
+
59
+ override inputEmptyValue(): any {
60
+ return null
61
+ }
62
+
40
63
  override formInputComponent(): any {
41
- throw new Error('Not implemented.')
64
+ return this.defineFormInputComponent((props, { emit }) => {
65
+ return () => h(LensAvatarInput, {
66
+ 'label': this.formInputLabel(),
67
+ 'help': this.help() || undefined,
68
+ 'accept': this.accept(),
69
+ 'imageType': this.imageType(),
70
+ 'modelValue': props.modelValue,
71
+ 'validation': props.validation,
72
+ 'onUpdate:modelValue': (value: any) => {
73
+ emit('update:modelValue', value)
74
+ }
75
+ })
76
+ })
77
+ }
78
+
79
+ /** The `accept` attribute for the file picker. */
80
+ accept(): string {
81
+ return this.data.accept || 'image/*'
82
+ }
83
+
84
+ /** The picker preview shape. */
85
+ imageType(): 'circle' | 'rectangle' {
86
+ return this.data.imageType || 'circle'
87
+ }
88
+
89
+ /** The companion name key resolved against the active language, if any. */
90
+ nameKey(): string | null {
91
+ return (this.ctx.lang === 'ja' ? this.data.nameJa : this.data.nameEn) ?? null
92
+ }
93
+
94
+ /** The configured English / Japanese display-name keys. */
95
+ nameKeys(): { en: string | null; ja: string | null } {
96
+ return { en: this.data.nameEn ?? null, ja: this.data.nameJa ?? null }
42
97
  }
43
98
  }
@@ -81,12 +81,25 @@ export abstract class Field<T extends FieldData> {
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * The field key sorts are emitted under — the catalog sends it to the backend,
86
+ * which orders by that field's column. Defaults to the field's own key; a
87
+ * subtype whose own value isn't meaningful to sort on can redirect it (e.g. an
88
+ * avatar sorts by its display-name companion). A `null` makes the field
89
+ * unsortable, so no sort menu is shown.
90
+ */
91
+ protected sortKey(): string | null {
92
+ return this.data.key
93
+ }
94
+
84
95
  /**
85
96
  * Renders the table sort menu for the field. The sort maybe null when
86
- * the field `sortable` option is false.
97
+ * the field `sortable` option is false (or it has no sortable key).
87
98
  */
88
99
  tableSortMenu(onSortUpdated: (sort: LensQuerySort) => void): DropdownSection | null {
89
- if (!this.data.sortable) {
100
+ const sortKey = this.sortKey()
101
+
102
+ if (!this.data.sortable || sortKey === null) {
90
103
  return null
91
104
  }
92
105
 
@@ -100,8 +113,8 @@ export abstract class Field<T extends FieldData> {
100
113
  return {
101
114
  type: 'menu',
102
115
  options: [
103
- { label: t.sort_asc, onClick: () => onSortUpdated?.([this.data.key, 'asc']) },
104
- { label: t.sort_desc, onClick: () => onSortUpdated?.([this.data.key, 'desc']) }
116
+ { label: t.sort_asc, onClick: () => onSortUpdated?.([sortKey, 'asc']) },
117
+ { label: t.sort_desc, onClick: () => onSortUpdated?.([sortKey, 'desc']) }
105
118
  ]
106
119
  }
107
120
  }
@@ -9,6 +9,8 @@ import {
9
9
  decimal,
10
10
  decimalOrHyphen,
11
11
  email,
12
+ fileExtension,
13
+ maxFileSize,
12
14
  maxLength,
13
15
  maxValue,
14
16
  minLength,
@@ -126,6 +128,10 @@ function mapRule(rule: Exclude<Rule, EachRule>): ValidationRuleWithParams | null
126
128
  return after(resolveDate(rule.date))
127
129
  case 'after_or_equal':
128
130
  return afterOrEqual(resolveDate(rule.date))
131
+ case 'file_extension':
132
+ return fileExtension(rule.extensions)
133
+ case 'max_file_size':
134
+ return maxFileSize(rule.size)
129
135
  default: {
130
136
  const _exhaustive: never = rule
131
137
  throw new Error(`Unsupported rule type: ${(_exhaustive as Rule).type}`)
@@ -63,12 +63,20 @@ function openFileSelect() {
63
63
  }
64
64
 
65
65
  function onFileSelect(e: Event) {
66
- const file = ((e.target as HTMLInputElement).files ?? [])[0]
66
+ const input = e.target as HTMLInputElement
67
+ const file = (input.files ?? [])[0]
67
68
 
68
69
  emit('update:model-value', file ?? null)
69
70
  emit('change', file ?? null)
70
71
 
71
72
  file && props.validation?.$touch()
73
+
74
+ // Clear the native input after capturing the file, so re-selecting the *same*
75
+ // file fires `change` again (the browser suppresses it when the value is
76
+ // unchanged). Matters when a consumer rejects/undoes a pick (e.g. a failed
77
+ // upload) and the user re-picks the same image. The preview reads the model,
78
+ // not the input, so clearing it is safe.
79
+ input.value = ''
72
80
  }
73
81
 
74
82
  function onFileDelete() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.52.1",
3
+ "version": "4.54.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",
@@ -59,8 +59,8 @@
59
59
  "@iconify-json/ph": "^1.2.2",
60
60
  "@iconify-json/ri": "^1.2.10",
61
61
  "@popperjs/core": "^2.11.8",
62
- "@sentry/browser": "^10.58.0",
63
- "@sentry/vue": "^10.58.0",
62
+ "@sentry/browser": "^10.62.0",
63
+ "@sentry/vue": "^10.62.0",
64
64
  "@tanstack/vue-virtual": "3.0.0-beta.62",
65
65
  "@tinyhttp/content-disposition": "^2.2.4",
66
66
  "@tinyhttp/cookie": "^2.1.1",
@@ -71,7 +71,7 @@
71
71
  "@types/markdown-it": "^14.1.2",
72
72
  "@types/qs": "^6.15.1",
73
73
  "@vitejs/plugin-vue": "^6.0.7",
74
- "@vue/reactivity": "^3.5.38",
74
+ "@vue/reactivity": "^3.5.39",
75
75
  "@vuelidate/core": "^2.0.3",
76
76
  "@vuelidate/validators": "^2.0.4",
77
77
  "@vueuse/core": "^14.3.0",
@@ -91,11 +91,11 @@
91
91
  "postcss": "^8.5.15",
92
92
  "postcss-nested": "^7.0.2",
93
93
  "punycode": "^2.3.1",
94
- "qs": "^6.15.2",
94
+ "qs": "^6.15.3",
95
95
  "unplugin-icons": "^23.0.1",
96
96
  "v-calendar": "3.0.1",
97
- "vite": "^7.3.5",
98
- "vue": "^3.5.38",
97
+ "vite": "^7.3.6",
98
+ "vue": "^3.5.39",
99
99
  "vue-draggable-plus": "^0.6.1",
100
100
  "vue-router": "^5.1.0"
101
101
  },
@@ -104,18 +104,18 @@
104
104
  "@histoire/plugin-vue": "1.0.0-beta.1",
105
105
  "@release-it/conventional-changelog": "^11.0.1",
106
106
  "@types/jsdom": "^28.0.3",
107
- "@types/node": "^26.0.0",
108
- "@typescript-eslint/rule-tester": "^8.61.1",
107
+ "@types/node": "^26.0.1",
108
+ "@typescript-eslint/rule-tester": "^8.62.0",
109
109
  "@vitest/coverage-v8": "^4.1.9",
110
110
  "@vue/test-utils": "^2.4.11",
111
111
  "eslint": "^9.39.4",
112
- "happy-dom": "^20.10.5",
112
+ "happy-dom": "^20.10.6",
113
113
  "histoire": "1.0.0-beta.1",
114
- "release-it": "^20.2.0",
114
+ "release-it": "^20.2.1",
115
115
  "typescript": "~6.0.3",
116
116
  "vitepress": "^2.0.0-alpha.17",
117
117
  "vitest": "^4.1.9",
118
118
  "vue-tsc": "^3.3.5"
119
119
  },
120
- "packageManager": "pnpm@11.7.0"
120
+ "packageManager": "pnpm@11.9.0"
121
121
  }