@pilotiq/pilotiq 0.2.0 → 0.4.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +2 -2
- package/dist/actions/Action.d.ts +25 -0
- package/dist/actions/Action.d.ts.map +1 -1
- package/dist/actions/Action.js +25 -0
- package/dist/actions/Action.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts +0 -14
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +28 -0
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/fields/BuilderField.d.ts +27 -1
- package/dist/fields/BuilderField.d.ts.map +1 -1
- package/dist/fields/BuilderField.js +36 -1
- package/dist/fields/BuilderField.js.map +1 -1
- package/dist/fields/FileUploadField.d.ts +65 -0
- package/dist/fields/FileUploadField.d.ts.map +1 -1
- package/dist/fields/FileUploadField.js +72 -0
- package/dist/fields/FileUploadField.js.map +1 -1
- package/dist/fields/RepeaterField.d.ts +34 -1
- package/dist/fields/RepeaterField.d.ts.map +1 -1
- package/dist/fields/RepeaterField.js +43 -1
- package/dist/fields/RepeaterField.js.map +1 -1
- package/dist/fields/RowButton.d.ts +9 -2
- package/dist/fields/RowButton.d.ts.map +1 -1
- package/dist/fields/TextField.d.ts +106 -0
- package/dist/fields/TextField.d.ts.map +1 -1
- package/dist/fields/TextField.js +115 -0
- package/dist/fields/TextField.js.map +1 -1
- package/dist/filters/queryBuilder/Constraint.d.ts +1 -1
- package/dist/filters/queryBuilder/Constraint.d.ts.map +1 -1
- package/dist/filters/queryBuilder/TextConstraint.d.ts.map +1 -1
- package/dist/filters/queryBuilder/TextConstraint.js +2 -3
- package/dist/filters/queryBuilder/TextConstraint.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts +1 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/pageData.d.ts +11 -0
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +31 -0
- package/dist/pageData.js.map +1 -1
- package/dist/react/FieldLabelSlotRegistry.d.ts +26 -0
- package/dist/react/FieldLabelSlotRegistry.d.ts.map +1 -0
- package/dist/react/FieldLabelSlotRegistry.js +16 -0
- package/dist/react/FieldLabelSlotRegistry.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +120 -9
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +32 -3
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts +12 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +5 -4
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/FileUploadInput.d.ts +17 -4
- package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
- package/dist/react/fields/FileUploadInput.js +204 -25
- package/dist/react/fields/FileUploadInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +33 -2
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +5 -1
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +17 -2
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/rowChromeButton.d.ts +24 -5
- package/dist/react/fields/rowChromeButton.d.ts.map +1 -1
- package/dist/react/fields/rowChromeButton.js +51 -8
- package/dist/react/fields/rowChromeButton.js.map +1 -1
- package/dist/react/fields/textInputControls.d.ts +47 -0
- package/dist/react/fields/textInputControls.d.ts.map +1 -0
- package/dist/react/fields/textInputControls.js +134 -0
- package/dist/react/fields/textInputControls.js.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +21 -1
- package/dist/routes.js.map +1 -1
- package/dist/schema/Alert.d.ts +58 -0
- package/dist/schema/Alert.d.ts.map +1 -1
- package/dist/schema/Alert.js +68 -1
- package/dist/schema/Alert.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +32 -0
- package/dist/schema/resolveSchema.js.map +1 -1
- package/package.json +2 -1
- package/src/actions/Action.test.ts +47 -0
- package/src/actions/Action.ts +35 -0
- package/src/elements/dispatchForm.ts +28 -0
- package/src/fields/BuilderField.ts +38 -1
- package/src/fields/FileUploadField.test.ts +46 -0
- package/src/fields/FileUploadField.ts +90 -2
- package/src/fields/RepeaterField.ts +45 -1
- package/src/fields/RowButton.test.ts +70 -0
- package/src/fields/RowButton.ts +11 -1
- package/src/fields/TextField.test.ts +168 -0
- package/src/fields/TextField.ts +141 -1
- package/src/filters/QueryBuilderFilter.test.ts +18 -0
- package/src/filters/queryBuilder/Constraint.ts +1 -1
- package/src/filters/queryBuilder/TextConstraint.ts +5 -6
- package/src/orm/modelDefaults.ts +1 -1
- package/src/pageData.ts +33 -0
- package/src/react/FieldLabelSlotRegistry.ts +30 -0
- package/src/react/SchemaRenderer.tsx +238 -16
- package/src/react/fields/BuilderInput.tsx +37 -0
- package/src/react/fields/FieldShell.tsx +17 -2
- package/src/react/fields/FileUploadInput.tsx +516 -85
- package/src/react/fields/RepeaterInput.tsx +39 -0
- package/src/react/fields/TextLikeInput.tsx +22 -2
- package/src/react/fields/rowChromeButton.tsx +102 -6
- package/src/react/fields/textInputControls.tsx +238 -0
- package/src/react/index.ts +1 -0
- package/src/routes.ts +21 -1
- package/src/schema/Alert.test.ts +46 -0
- package/src/schema/Alert.ts +90 -8
- package/src/schema/resolveSchema.ts +32 -0
|
@@ -1,39 +1,83 @@
|
|
|
1
1
|
import React, { useRef, useState } from 'react'
|
|
2
|
-
import {
|
|
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,
|
|
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:
|
|
19
|
-
defaultValue:
|
|
20
|
-
disabled:
|
|
21
|
-
accept:
|
|
22
|
-
maxSize:
|
|
23
|
-
multiple:
|
|
24
|
-
preview:
|
|
25
|
-
directory:
|
|
26
|
-
uploadUrl:
|
|
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
|
|
57
|
+
const fs = useFieldState(name)
|
|
29
58
|
const { notify } = useToast()
|
|
30
|
-
const inputRef
|
|
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
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
//
|
|
107
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
<
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
}
|