@pilotiq/pilotiq 0.1.0 → 0.3.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 (108) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Pilotiq.d.ts +20 -1
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js.map +1 -1
  7. package/dist/actions/Action.d.ts +25 -0
  8. package/dist/actions/Action.d.ts.map +1 -1
  9. package/dist/actions/Action.js +25 -0
  10. package/dist/actions/Action.js.map +1 -1
  11. package/dist/elements/dispatchForm.d.ts +0 -14
  12. package/dist/elements/dispatchForm.d.ts.map +1 -1
  13. package/dist/elements/dispatchForm.js +28 -0
  14. package/dist/elements/dispatchForm.js.map +1 -1
  15. package/dist/fields/BuilderField.d.ts +27 -1
  16. package/dist/fields/BuilderField.d.ts.map +1 -1
  17. package/dist/fields/BuilderField.js +36 -1
  18. package/dist/fields/BuilderField.js.map +1 -1
  19. package/dist/fields/FileUploadField.d.ts +65 -0
  20. package/dist/fields/FileUploadField.d.ts.map +1 -1
  21. package/dist/fields/FileUploadField.js +72 -0
  22. package/dist/fields/FileUploadField.js.map +1 -1
  23. package/dist/fields/RepeaterField.d.ts +34 -1
  24. package/dist/fields/RepeaterField.d.ts.map +1 -1
  25. package/dist/fields/RepeaterField.js +43 -1
  26. package/dist/fields/RepeaterField.js.map +1 -1
  27. package/dist/fields/RowButton.d.ts +9 -2
  28. package/dist/fields/RowButton.d.ts.map +1 -1
  29. package/dist/fields/TextField.d.ts +106 -0
  30. package/dist/fields/TextField.d.ts.map +1 -1
  31. package/dist/fields/TextField.js +115 -0
  32. package/dist/fields/TextField.js.map +1 -1
  33. package/dist/filters/queryBuilder/Constraint.d.ts +1 -1
  34. package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -1
  35. package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -1
  36. package/dist/filters/queryBuilder/TextConstraint.js +2 -3
  37. package/dist/filters/queryBuilder/TextConstraint.js.map +1 -1
  38. package/dist/orm/modelDefaults.d.ts +1 -1
  39. package/dist/orm/modelDefaults.d.ts.map +1 -1
  40. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  41. package/dist/react/SchemaRenderer.js +108 -7
  42. package/dist/react/SchemaRenderer.js.map +1 -1
  43. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  44. package/dist/react/fields/BuilderInput.js +32 -3
  45. package/dist/react/fields/BuilderInput.js.map +1 -1
  46. package/dist/react/fields/FieldShell.d.ts +9 -1
  47. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  48. package/dist/react/fields/FieldShell.js +4 -3
  49. package/dist/react/fields/FieldShell.js.map +1 -1
  50. package/dist/react/fields/FileUploadInput.d.ts +17 -4
  51. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  52. package/dist/react/fields/FileUploadInput.js +204 -25
  53. package/dist/react/fields/FileUploadInput.js.map +1 -1
  54. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  55. package/dist/react/fields/RepeaterInput.js +33 -2
  56. package/dist/react/fields/RepeaterInput.js.map +1 -1
  57. package/dist/react/fields/TextLikeInput.d.ts +5 -1
  58. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  59. package/dist/react/fields/TextLikeInput.js +17 -2
  60. package/dist/react/fields/TextLikeInput.js.map +1 -1
  61. package/dist/react/fields/rowChromeButton.d.ts +24 -5
  62. package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
  63. package/dist/react/fields/rowChromeButton.js +51 -8
  64. package/dist/react/fields/rowChromeButton.js.map +1 -1
  65. package/dist/react/fields/textInputControls.d.ts +47 -0
  66. package/dist/react/fields/textInputControls.d.ts.map +1 -0
  67. package/dist/react/fields/textInputControls.js +134 -0
  68. package/dist/react/fields/textInputControls.js.map +1 -0
  69. package/dist/routes.d.ts.map +1 -1
  70. package/dist/routes.js +32 -1
  71. package/dist/routes.js.map +1 -1
  72. package/dist/schema/Alert.d.ts +58 -0
  73. package/dist/schema/Alert.d.ts.map +1 -1
  74. package/dist/schema/Alert.js +68 -1
  75. package/dist/schema/Alert.js.map +1 -1
  76. package/dist/schema/resolveSchema.d.ts.map +1 -1
  77. package/dist/schema/resolveSchema.js +32 -0
  78. package/dist/schema/resolveSchema.js.map +1 -1
  79. package/package.json +12 -11
  80. package/src/Pilotiq.test.ts +78 -0
  81. package/src/Pilotiq.ts +20 -1
  82. package/src/actions/Action.test.ts +47 -0
  83. package/src/actions/Action.ts +35 -0
  84. package/src/elements/dispatchForm.ts +28 -0
  85. package/src/fields/BuilderField.ts +38 -1
  86. package/src/fields/FileUploadField.test.ts +46 -0
  87. package/src/fields/FileUploadField.ts +90 -2
  88. package/src/fields/RepeaterField.ts +45 -1
  89. package/src/fields/RowButton.test.ts +70 -0
  90. package/src/fields/RowButton.ts +11 -1
  91. package/src/fields/TextField.test.ts +168 -0
  92. package/src/fields/TextField.ts +141 -1
  93. package/src/filters/QueryBuilderFilter.test.ts +18 -0
  94. package/src/filters/queryBuilder/Constraint.ts +1 -1
  95. package/src/filters/queryBuilder/TextConstraint.ts +5 -6
  96. package/src/orm/modelDefaults.ts +1 -1
  97. package/src/react/SchemaRenderer.tsx +222 -14
  98. package/src/react/fields/BuilderInput.tsx +37 -0
  99. package/src/react/fields/FieldShell.tsx +13 -2
  100. package/src/react/fields/FileUploadInput.tsx +516 -85
  101. package/src/react/fields/RepeaterInput.tsx +39 -0
  102. package/src/react/fields/TextLikeInput.tsx +22 -2
  103. package/src/react/fields/rowChromeButton.tsx +102 -6
  104. package/src/react/fields/textInputControls.tsx +238 -0
  105. package/src/routes.ts +33 -1
  106. package/src/schema/Alert.test.ts +46 -0
  107. package/src/schema/Alert.ts +90 -8
  108. package/src/schema/resolveSchema.ts +32 -0
@@ -1,39 +1,83 @@
1
1
  import React, { useRef, useState } from 'react'
2
- import { UploadIcon, XIcon, FileIcon, Loader2Icon } from 'lucide-react'
2
+ import {
3
+ UploadIcon, XIcon, FileIcon, Loader2Icon,
4
+ GripVerticalIcon, DownloadIcon,
5
+ } from 'lucide-react'
6
+ import ReactCrop, {
7
+ type Crop, type PixelCrop,
8
+ centerCrop, makeAspectCrop, convertToPixelCrop,
9
+ } from 'react-image-crop'
3
10
  import { useFieldState } from '../FormStateContext.js'
4
11
  import { useToast } from '../Toaster.js'
5
12
  import { Button } from '../ui/button.js'
13
+ import {
14
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
15
+ } from '../ui/dialog.js'
16
+ import { reorderRows } from './RepeaterInput.js'
6
17
 
7
18
  /**
8
19
  * File upload UI. On file pick → POST multipart to `uploadUrl` →
9
20
  * stash returned URL in form state. Single-file shows the URL +
10
21
  * preview thumb (when image). Multi-file accumulates a list.
11
- *
12
- * No DnD upload, no chunked uploads, no client-side image processing
13
- * — v1 is "pick a file, send it, store the URL." Adapters do the work.
14
22
  */
15
23
  export function FileUploadInput({
16
- name, defaultValue, disabled, accept, maxSize, multiple, preview, directory, uploadUrl,
24
+ name, defaultValue, disabled,
25
+ accept, maxSize, multiple, preview, directory, uploadUrl,
26
+ downloadable = false,
27
+ openable = false,
28
+ reorderable = false,
29
+ appendFiles = false,
30
+ panelLayout = 'list',
31
+ automaticallyResize,
32
+ imageEditor = false,
33
+ imageEditorAspectRatioOptions,
34
+ circleCropper = false,
35
+ automaticallyCropImagesToAspectRatio = false,
17
36
  }: {
18
- name: string
19
- defaultValue: unknown
20
- disabled: boolean
21
- accept: string[] | undefined
22
- maxSize: number | undefined
23
- multiple: boolean
24
- preview: boolean
25
- directory: string | undefined
26
- uploadUrl: string | undefined
37
+ name: string
38
+ defaultValue: unknown
39
+ disabled: boolean
40
+ accept: string[] | undefined
41
+ maxSize: number | undefined
42
+ multiple: boolean
43
+ preview: boolean
44
+ directory: string | undefined
45
+ uploadUrl: string | undefined
46
+ downloadable: boolean
47
+ openable: boolean
48
+ reorderable: boolean
49
+ appendFiles: boolean
50
+ panelLayout: 'list' | 'grid' | 'integrated'
51
+ automaticallyResize?: { width: number; height: number }
52
+ imageEditor?: boolean
53
+ imageEditorAspectRatioOptions?: Array<{ ratio: number; label: string }>
54
+ circleCropper?: boolean
55
+ automaticallyCropImagesToAspectRatio?: boolean
27
56
  }): React.ReactElement {
28
- const fs = useFieldState(name)
57
+ const fs = useFieldState(name)
29
58
  const { notify } = useToast()
30
- const inputRef = useRef<HTMLInputElement | null>(null)
59
+ const inputRef = useRef<HTMLInputElement | null>(null)
60
+
61
+ // Drag-and-drop state (reorderable only)
62
+ const [dragFromIdx, setDragFromIdx] = useState<number | null>(null)
63
+ const [dropAt, setDropAt] = useState<number | null>(null)
64
+
65
+ // Image editor state
66
+ const [editorState, setEditorState] = useState<{
67
+ src: string
68
+ file: File
69
+ resolve: (f: File) => void
70
+ reject: () => void
71
+ } | null>(null)
72
+ const [crop, setCrop] = useState<Crop>({ unit: '%', x: 5, y: 5, width: 90, height: 90 })
73
+ const [completedCrop, setCompletedCrop] = useState<PixelCrop | undefined>()
74
+ const [activeRatio, setActiveRatio] = useState<number | undefined>()
75
+ const imgRef = useRef<HTMLImageElement | null>(null)
31
76
 
32
77
  const toUrls = (v: unknown): string[] => {
33
78
  if (v === undefined || v === null || v === '') return []
34
79
  if (Array.isArray(v)) return v.map(String)
35
80
  if (typeof v === 'string') {
36
- // JSON-array string from a previous round-trip
37
81
  if (v.startsWith('[')) {
38
82
  try {
39
83
  const arr = JSON.parse(v)
@@ -46,7 +90,7 @@ export function FileUploadInput({
46
90
  }
47
91
 
48
92
  const [localUrls, setLocalUrls] = useState<string[]>(toUrls(defaultValue))
49
- const urls = fs.controlled ? toUrls(fs.value) : localUrls
93
+ const urls = fs.controlled ? toUrls(fs.value) : localUrls
50
94
  const [busy, setBusy] = useState(false)
51
95
 
52
96
  const setUrls = (next: string[]): void => {
@@ -60,6 +104,84 @@ export function FileUploadInput({
60
104
  }
61
105
  }
62
106
 
107
+ // ── Image editor helpers ──────────────────────────────────────────────────
108
+
109
+ const onImgLoad = (e: React.SyntheticEvent<HTMLImageElement>): void => {
110
+ const img = e.currentTarget
111
+ const { naturalWidth: nw, naturalHeight: nh } = img
112
+ const initialCrop = activeRatio
113
+ ? centerCrop(makeAspectCrop({ unit: '%', width: 90 }, activeRatio, nw, nh), nw, nh)
114
+ : centerCrop({ unit: '%', width: 90, height: 90 }, nw, nh)
115
+ setCrop(initialCrop)
116
+ setCompletedCrop(convertToPixelCrop(initialCrop, img.width, img.height))
117
+ }
118
+
119
+ const onRatioChange = (ratio: number | undefined): void => {
120
+ setActiveRatio(ratio)
121
+ const img = imgRef.current
122
+ if (!img) return
123
+ const { naturalWidth: nw, naturalHeight: nh } = img
124
+ const next = ratio
125
+ ? centerCrop(makeAspectCrop({ unit: '%', width: 90 }, ratio, nw, nh), nw, nh)
126
+ : centerCrop({ unit: '%', width: 90, height: 90 }, nw, nh)
127
+ setCrop(next)
128
+ setCompletedCrop(convertToPixelCrop(next, img.width, img.height))
129
+ }
130
+
131
+ const handleEditorApply = async (): Promise<void> => {
132
+ if (!editorState || !completedCrop || !imgRef.current) return
133
+ try {
134
+ const blob = await cropToBlob(imgRef.current, completedCrop, circleCropper, /* fromDisplay */ true)
135
+ editorState.resolve(new File([blob], editorState.file.name, { type: blob.type }))
136
+ } catch {
137
+ editorState.reject()
138
+ } finally {
139
+ URL.revokeObjectURL(editorState.src)
140
+ setEditorState(null)
141
+ }
142
+ }
143
+
144
+ const handleEditorCancel = (): void => {
145
+ if (!editorState) return
146
+ editorState.reject()
147
+ URL.revokeObjectURL(editorState.src)
148
+ setEditorState(null)
149
+ }
150
+
151
+ /** Intercepts a file through the crop editor (or auto-crop) before upload. */
152
+ const prepareFile = (file: File): Promise<File> => {
153
+ if (!imageEditor) return Promise.resolve(file)
154
+
155
+ const src = URL.createObjectURL(file)
156
+
157
+ // Auto-crop: load image, compute center crop at first ratio, skip modal
158
+ if (automaticallyCropImagesToAspectRatio && imageEditorAspectRatioOptions?.length) {
159
+ const ratio = imageEditorAspectRatioOptions[0]!.ratio
160
+ return new Promise<File>((resolve, reject) => {
161
+ const img = new Image()
162
+ img.onload = (): void => {
163
+ URL.revokeObjectURL(src)
164
+ const pct = centerCrop(
165
+ makeAspectCrop({ unit: '%', width: 90 }, ratio, img.naturalWidth, img.naturalHeight),
166
+ img.naturalWidth, img.naturalHeight,
167
+ )
168
+ const px = convertToPixelCrop(pct, img.naturalWidth, img.naturalHeight)
169
+ cropToBlob(img, px, circleCropper, /* fromDisplay */ false)
170
+ .then(blob => resolve(new File([blob], file.name, { type: blob.type })))
171
+ .catch(reject)
172
+ }
173
+ img.onerror = (): void => { URL.revokeObjectURL(src); reject(new Error('Image load failed')) }
174
+ img.src = src
175
+ })
176
+ }
177
+
178
+ // Manual editor: open modal, await user action
179
+ return new Promise<File>((resolve, reject) => {
180
+ setActiveRatio(imageEditorAspectRatioOptions?.[0]?.ratio)
181
+ setEditorState({ src, file, resolve, reject })
182
+ })
183
+ }
184
+
63
185
  const onPick = async (files: FileList | null): Promise<void> => {
64
186
  if (!files || files.length === 0) return
65
187
  if (!uploadUrl) {
@@ -67,113 +189,422 @@ export function FileUploadInput({
67
189
  return
68
190
  }
69
191
  setBusy(true)
70
- const next = multiple ? [...urls] : []
192
+ // appendFiles keeps existing URLs; default replaces them
193
+ const next = appendFiles ? [...urls] : []
71
194
  try {
72
195
  for (const file of Array.from(files)) {
196
+ let preparedFile: File
197
+ try {
198
+ preparedFile = await prepareFile(file)
199
+ } catch {
200
+ continue // user cancelled the editor for this file
201
+ }
73
202
  const fd = new FormData()
74
- fd.append('file', file)
203
+ fd.append('file', preparedFile)
75
204
  if (directory) fd.append('directory', directory)
76
205
  if (accept) fd.append('accept', accept.join(','))
77
206
  if (maxSize !== undefined) fd.append('maxSize', String(maxSize))
207
+ if (automaticallyResize) {
208
+ fd.append('resize_width', String(automaticallyResize.width))
209
+ fd.append('resize_height', String(automaticallyResize.height))
210
+ }
78
211
  fd.append('fieldName', name)
79
- const res = await fetch(uploadUrl, {
80
- method: 'POST',
81
- body: fd,
82
- headers: { Accept: 'application/json' },
83
- })
212
+ const res = await fetch(uploadUrl, { method: 'POST', body: fd, headers: { Accept: 'application/json' } })
84
213
  const json = await res.json().catch(() => ({})) as { ok?: boolean; url?: string; error?: string }
85
214
  if (!res.ok || !json.ok || !json.url) {
86
215
  notify({ type: 'error', title: 'Upload failed', body: json.error ?? `HTTP ${res.status}` })
87
216
  continue
88
217
  }
89
218
  next.push(json.url)
90
- if (!multiple) break
219
+ if (!multiple && !appendFiles) break
91
220
  }
92
221
  setUrls(next)
93
222
  } finally {
94
223
  setBusy(false)
95
- // Reset the input so the user can re-pick the same file (browsers
96
- // suppress onChange when the value hasn't changed).
97
224
  if (inputRef.current) inputRef.current.value = ''
98
225
  }
99
226
  }
100
227
 
101
- const removeAt = (i: number): void => {
102
- const next = urls.filter((_, idx) => idx !== i)
103
- setUrls(next)
228
+ const removeAt = (i: number): void => setUrls(urls.filter((_, idx) => idx !== i))
229
+
230
+ // ── Drag handlers ─────────────────────────────────────────────────────────
231
+
232
+ const onDragStart = (e: React.DragEvent, i: number): void => {
233
+ setDragFromIdx(i)
234
+ e.dataTransfer.effectAllowed = 'move'
235
+ e.dataTransfer.setData('text/plain', String(i))
236
+ }
237
+
238
+ const onDragEnd = (): void => { setDragFromIdx(null); setDropAt(null) }
239
+
240
+ // Vertical list: insert before/after based on cursor Y vs item midpoint
241
+ const onItemDragOver = (e: React.DragEvent, i: number): void => {
242
+ if (dragFromIdx == null) return
243
+ e.preventDefault()
244
+ e.dataTransfer.dropEffect = 'move'
245
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
246
+ const before = e.clientY < rect.top + rect.height / 2
247
+ setDropAt(before ? i : i + 1)
248
+ }
249
+
250
+ // Grid tiles: insert before/after based on cursor X vs tile midpoint
251
+ const onTileDragOver = (e: React.DragEvent, i: number): void => {
252
+ if (dragFromIdx == null) return
253
+ e.preventDefault()
254
+ e.dataTransfer.dropEffect = 'move'
255
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
256
+ const before = e.clientX < rect.left + rect.width / 2
257
+ setDropAt(before ? i : i + 1)
258
+ }
259
+
260
+ const onDrop = (e: React.DragEvent): void => {
261
+ e.preventDefault()
262
+ if (dragFromIdx == null || dropAt == null) { onDragEnd(); return }
263
+ const next = reorderRows(urls, dragFromIdx, dropAt)
264
+ if (next !== urls) setUrls(next)
265
+ onDragEnd()
104
266
  }
105
267
 
106
- // Hidden input for form-post fallback. Multi-file mode encodes as JSON
107
- // so the server's coerceFormValues fileUpload branch can decode.
268
+ // ── Helpers ───────────────────────────────────────────────────────────────
269
+
108
270
  const hiddenValue = multiple ? JSON.stringify(urls) : (urls[0] ?? '')
271
+ const isGrid = panelLayout === 'grid' || panelLayout === 'integrated'
272
+
273
+ // ── Shared sub-renders ────────────────────────────────────────────────────
274
+
275
+ const thumbOrIcon = (url: string): React.ReactElement =>
276
+ preview && isImage(url)
277
+ ? <img src={url} alt="" className="size-full object-cover" />
278
+ : <FileIcon className="size-8 text-muted-foreground" />
279
+
280
+ const downloadBtn = (url: string): React.ReactElement => (
281
+ <a
282
+ href={url}
283
+ download={fileNameFrom(url)}
284
+ className="text-muted-foreground hover:text-foreground"
285
+ aria-label="Download file"
286
+ onClick={(e) => e.stopPropagation()}
287
+ >
288
+ <DownloadIcon className="size-4" />
289
+ </a>
290
+ )
291
+
292
+ // ── Render ────────────────────────────────────────────────────────────────
109
293
 
110
294
  return (
111
295
  <div className="flex flex-col gap-2">
112
296
  <input type="hidden" name={name} value={hiddenValue} readOnly />
113
297
 
114
- <div className="flex items-center gap-2">
115
- <Button
116
- type="button"
117
- variant="outline"
118
- size="sm"
119
- onClick={() => inputRef.current?.click()}
120
- disabled={disabled || busy}
121
- >
122
- {busy ? (
123
- <Loader2Icon className="size-4 animate-spin" />
124
- ) : (
125
- <UploadIcon className="size-4" />
298
+ {/* Upload trigger — hidden in integrated mode (button lives inside the grid) */}
299
+ {panelLayout !== 'integrated' && (
300
+ <div className="flex items-center gap-2">
301
+ <Button
302
+ type="button"
303
+ variant="outline"
304
+ size="sm"
305
+ onClick={() => inputRef.current?.click()}
306
+ disabled={disabled || busy}
307
+ >
308
+ {busy
309
+ ? <Loader2Icon className="size-4 animate-spin" />
310
+ : <UploadIcon className="size-4" />
311
+ }
312
+ {multiple ? 'Choose files' : 'Choose file'}
313
+ </Button>
314
+ {maxSize !== undefined && (
315
+ <span className="text-xs text-muted-foreground">
316
+ Max {Math.round(maxSize / 1024)} KB
317
+ </span>
126
318
  )}
127
- {multiple ? 'Choose files' : 'Choose file'}
128
- </Button>
129
- <input
130
- ref={inputRef}
131
- type="file"
132
- className="hidden"
133
- accept={accept ? accept.join(',') : undefined}
134
- multiple={multiple}
135
- onChange={(e) => { void onPick(e.target.files) }}
136
- />
137
- {maxSize !== undefined && (
138
- <span className="text-xs text-muted-foreground">
139
- Max {Math.round(maxSize / 1024)} KB
140
- </span>
141
- )}
142
- </div>
143
-
144
- {urls.length > 0 && (
145
- <ul className="flex flex-col gap-1.5">
319
+ </div>
320
+ )}
321
+
322
+ <input
323
+ ref={inputRef}
324
+ type="file"
325
+ className="hidden"
326
+ accept={accept ? accept.join(',') : undefined}
327
+ multiple={multiple || appendFiles}
328
+ onChange={(e) => { void onPick(e.target.files) }}
329
+ />
330
+
331
+ {/* ── Grid / integrated layout ────────────────────────────────────── */}
332
+ {isGrid && (
333
+ <div className="flex flex-wrap gap-2">
146
334
  {urls.map((url, i) => (
147
- <li key={i} className="flex items-center gap-2 rounded-md border border-input bg-background px-2 py-1.5 text-sm">
148
- {preview && isImage(url)
149
- ? <img src={url} alt="" className="size-8 rounded object-cover" />
150
- : <FileIcon className="size-4 text-muted-foreground" />
151
- }
152
- <a
153
- href={url}
154
- target="_blank"
155
- rel="noopener noreferrer"
156
- className="flex-1 truncate hover:underline"
335
+ <React.Fragment key={i}>
336
+ {/* Drop indicator before tile */}
337
+ {reorderable && dropAt === i && dragFromIdx !== null && dragFromIdx !== i && dragFromIdx + 1 !== i && (
338
+ <div aria-hidden className="w-0.5 self-stretch rounded bg-primary" />
339
+ )}
340
+ <div
341
+ className={[
342
+ 'group relative flex flex-col items-center gap-1',
343
+ reorderable ? 'cursor-grab active:cursor-grabbing' : '',
344
+ dragFromIdx === i ? 'opacity-40' : '',
345
+ ].join(' ')}
346
+ draggable={reorderable}
347
+ onDragStart={reorderable ? (e) => onDragStart(e, i) : undefined}
348
+ onDragOver={reorderable ? (e) => onTileDragOver(e, i) : undefined}
349
+ onDragEnd={reorderable ? onDragEnd : undefined}
350
+ onDrop={reorderable ? onDrop : undefined}
157
351
  >
158
- {fileNameFrom(url)}
159
- </a>
160
- <button
161
- type="button"
162
- className="text-muted-foreground hover:text-destructive"
163
- onClick={() => removeAt(i)}
164
- disabled={disabled}
165
- aria-label="Remove file"
352
+ {/* Thumbnail tile */}
353
+ <div className="relative size-20 rounded-md border border-input bg-muted overflow-hidden flex items-center justify-center">
354
+ {openable
355
+ ? (
356
+ <a href={url} target="_blank" rel="noopener noreferrer" className="size-full block">
357
+ {thumbOrIcon(url)}
358
+ </a>
359
+ )
360
+ : thumbOrIcon(url)
361
+ }
362
+ {/* Hover overlay — actions */}
363
+ <div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
364
+ {downloadable && (
365
+ <a
366
+ href={url}
367
+ download={fileNameFrom(url)}
368
+ className="rounded p-0.5 text-white hover:text-primary-foreground"
369
+ aria-label="Download file"
370
+ onClick={(e) => e.stopPropagation()}
371
+ >
372
+ <DownloadIcon className="size-3.5" />
373
+ </a>
374
+ )}
375
+ {!disabled && (
376
+ <button
377
+ type="button"
378
+ className="rounded p-0.5 text-white hover:text-red-300"
379
+ onClick={() => removeAt(i)}
380
+ aria-label="Remove file"
381
+ >
382
+ <XIcon className="size-3.5" />
383
+ </button>
384
+ )}
385
+ </div>
386
+ </div>
387
+ <span className="w-20 truncate text-center text-xs text-muted-foreground">
388
+ {fileNameFrom(url)}
389
+ </span>
390
+ </div>
391
+ </React.Fragment>
392
+ ))}
393
+
394
+ {/* Drop indicator after last tile */}
395
+ {reorderable && dropAt === urls.length && dragFromIdx !== null && dragFromIdx !== urls.length - 1 && (
396
+ <div aria-hidden className="w-0.5 self-stretch rounded bg-primary" />
397
+ )}
398
+
399
+ {/* Integrated: "Add" tile embedded in the grid */}
400
+ {panelLayout === 'integrated' && !disabled && (
401
+ <button
402
+ type="button"
403
+ className="flex size-20 flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed border-input text-muted-foreground hover:border-primary hover:text-foreground transition-colors"
404
+ onClick={() => inputRef.current?.click()}
405
+ disabled={busy}
406
+ >
407
+ {busy
408
+ ? <Loader2Icon className="size-6 animate-spin" />
409
+ : <UploadIcon className="size-6" />
410
+ }
411
+ <span className="text-xs">Add</span>
412
+ </button>
413
+ )}
414
+ </div>
415
+ )}
416
+
417
+ {/* ── List layout ─────────────────────────────────────────────────── */}
418
+ {!isGrid && urls.length > 0 && (
419
+ <ul className="flex flex-col">
420
+ {urls.map((url, i) => (
421
+ <React.Fragment key={i}>
422
+ {/* Drop indicator before row */}
423
+ {reorderable && dropAt === i && dragFromIdx !== null && dragFromIdx !== i && dragFromIdx + 1 !== i && (
424
+ <li aria-hidden className="h-0.5 rounded bg-primary mx-1" />
425
+ )}
426
+ <li
427
+ className={[
428
+ 'flex items-center gap-2 rounded-md border border-input bg-background px-2 py-1.5 text-sm',
429
+ i > 0 ? 'mt-1.5' : '',
430
+ dragFromIdx === i ? 'opacity-40' : '',
431
+ ].join(' ')}
432
+ draggable={reorderable}
433
+ onDragStart={reorderable ? (e) => onDragStart(e, i) : undefined}
434
+ onDragOver={reorderable ? (e) => onItemDragOver(e, i) : undefined}
435
+ onDragEnd={reorderable ? onDragEnd : undefined}
436
+ onDrop={reorderable ? onDrop : undefined}
166
437
  >
167
- <XIcon className="size-4" />
168
- </button>
169
- </li>
438
+ {reorderable && (
439
+ <GripVerticalIcon
440
+ className="size-4 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
441
+ aria-hidden
442
+ />
443
+ )}
444
+ {preview && isImage(url)
445
+ ? openable
446
+ ? (
447
+ <a href={url} target="_blank" rel="noopener noreferrer">
448
+ <img src={url} alt="" className="size-8 rounded object-cover shrink-0" />
449
+ </a>
450
+ )
451
+ : <img src={url} alt="" className="size-8 rounded object-cover shrink-0" />
452
+ : <FileIcon className="size-4 shrink-0 text-muted-foreground" />
453
+ }
454
+ <a
455
+ href={url}
456
+ target="_blank"
457
+ rel="noopener noreferrer"
458
+ className="flex-1 truncate hover:underline"
459
+ >
460
+ {fileNameFrom(url)}
461
+ </a>
462
+ {downloadable && downloadBtn(url)}
463
+ <button
464
+ type="button"
465
+ className="shrink-0 text-muted-foreground hover:text-destructive"
466
+ onClick={() => removeAt(i)}
467
+ disabled={disabled}
468
+ aria-label="Remove file"
469
+ >
470
+ <XIcon className="size-4" />
471
+ </button>
472
+ </li>
473
+ </React.Fragment>
170
474
  ))}
475
+ {/* Drop indicator after last row */}
476
+ {reorderable && dropAt === urls.length && dragFromIdx !== null && dragFromIdx !== urls.length - 1 && (
477
+ <li aria-hidden className="mt-1.5 h-0.5 rounded bg-primary mx-1" />
478
+ )}
171
479
  </ul>
172
480
  )}
481
+
482
+ {/* Max-size hint for integrated mode (no separate button row) */}
483
+ {panelLayout === 'integrated' && maxSize !== undefined && (
484
+ <p className="text-xs text-muted-foreground">Max {Math.round(maxSize / 1024)} KB</p>
485
+ )}
486
+
487
+ {/* ── Image editor modal ───────────────────────────────────────────── */}
488
+ {editorState && (
489
+ <Dialog open onOpenChange={(open) => { if (!open) handleEditorCancel() }}>
490
+ <DialogContent className="max-w-2xl gap-4">
491
+ <DialogHeader>
492
+ <DialogTitle>Crop image</DialogTitle>
493
+ </DialogHeader>
494
+
495
+ {/* Aspect ratio picker */}
496
+ {imageEditorAspectRatioOptions?.length && (
497
+ <div className="flex flex-wrap gap-2">
498
+ {imageEditorAspectRatioOptions.map((opt) => (
499
+ <button
500
+ key={opt.label}
501
+ type="button"
502
+ className={[
503
+ 'rounded border px-2.5 py-1 text-sm font-medium transition-colors',
504
+ activeRatio === opt.ratio
505
+ ? 'border-primary bg-primary text-primary-foreground'
506
+ : 'border-input bg-background hover:bg-muted',
507
+ ].join(' ')}
508
+ onClick={() => onRatioChange(opt.ratio)}
509
+ >
510
+ {opt.label}
511
+ </button>
512
+ ))}
513
+ <button
514
+ type="button"
515
+ className={[
516
+ 'rounded border px-2.5 py-1 text-sm font-medium transition-colors',
517
+ activeRatio === undefined
518
+ ? 'border-primary bg-primary text-primary-foreground'
519
+ : 'border-input bg-background hover:bg-muted',
520
+ ].join(' ')}
521
+ onClick={() => onRatioChange(undefined)}
522
+ >
523
+ Free
524
+ </button>
525
+ </div>
526
+ )}
527
+
528
+ <div className="flex justify-center overflow-auto">
529
+ <ReactCrop
530
+ crop={crop}
531
+ onChange={setCrop}
532
+ onComplete={setCompletedCrop}
533
+ {...(activeRatio !== undefined ? { aspect: activeRatio } : {})}
534
+ circularCrop={circleCropper}
535
+ >
536
+ <img
537
+ ref={imgRef}
538
+ src={editorState.src}
539
+ alt="Crop preview"
540
+ className="max-h-[55vh] max-w-full object-contain"
541
+ onLoad={onImgLoad}
542
+ />
543
+ </ReactCrop>
544
+ </div>
545
+
546
+ <DialogFooter>
547
+ <Button type="button" variant="outline" onClick={handleEditorCancel}>
548
+ Cancel
549
+ </Button>
550
+ <Button
551
+ type="button"
552
+ onClick={() => { void handleEditorApply() }}
553
+ disabled={!completedCrop?.width || !completedCrop?.height}
554
+ >
555
+ Apply crop
556
+ </Button>
557
+ </DialogFooter>
558
+ </DialogContent>
559
+ </Dialog>
560
+ )}
173
561
  </div>
174
562
  )
175
563
  }
176
564
 
565
+ /**
566
+ * Draw a crop region from `img` onto a canvas and return the result as a Blob.
567
+ * When `fromDisplay` is true, `pixelCrop` coordinates are in *display* pixels
568
+ * (as returned by ReactCrop's onComplete); they are scaled to the image's
569
+ * natural dimensions before drawing.
570
+ */
571
+ function cropToBlob(
572
+ img: HTMLImageElement,
573
+ pixelCrop: PixelCrop,
574
+ circular: boolean,
575
+ fromDisplay: boolean,
576
+ ): Promise<Blob> {
577
+ const scaleX = fromDisplay ? img.naturalWidth / img.width : 1
578
+ const scaleY = fromDisplay ? img.naturalHeight / img.height : 1
579
+ const w = Math.round(pixelCrop.width * scaleX)
580
+ const h = Math.round(pixelCrop.height * scaleY)
581
+
582
+ const canvas = document.createElement('canvas')
583
+ canvas.width = w
584
+ canvas.height = h
585
+ const ctx = canvas.getContext('2d')!
586
+
587
+ if (circular) {
588
+ ctx.beginPath()
589
+ ctx.arc(w / 2, h / 2, Math.min(w, h) / 2, 0, Math.PI * 2)
590
+ ctx.clip()
591
+ }
592
+
593
+ ctx.drawImage(
594
+ img,
595
+ pixelCrop.x * scaleX, pixelCrop.y * scaleY,
596
+ pixelCrop.width * scaleX, pixelCrop.height * scaleY,
597
+ 0, 0, w, h,
598
+ )
599
+
600
+ return new Promise((resolve, reject) => {
601
+ canvas.toBlob(
602
+ (blob) => blob ? resolve(blob) : reject(new Error('Canvas toBlob failed')),
603
+ 'image/webp', 0.92,
604
+ )
605
+ })
606
+ }
607
+
177
608
  function isImage(url: string): boolean {
178
609
  return /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(url)
179
610
  }