@xk2800/nextjs-template 0.7.1-beta.2 → 0.7.1-beta.3
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/components/ui/file-upload.tsx +103 -2
- package/package.json +1 -1
|
@@ -4,6 +4,8 @@ import * as React from "react"
|
|
|
4
4
|
import { Camera, ImagePlus, Upload, X } from "lucide-react"
|
|
5
5
|
|
|
6
6
|
import { Button } from "./button"
|
|
7
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./dialog"
|
|
8
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet"
|
|
7
9
|
import { cn } from "../../lib/utils"
|
|
8
10
|
|
|
9
11
|
type FileUploadProps = {
|
|
@@ -13,6 +15,13 @@ type FileUploadProps = {
|
|
|
13
15
|
gallery?: boolean
|
|
14
16
|
/** Drop files onto the component. */
|
|
15
17
|
dragDrop?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* Pressing the drop zone opens the picker instead of showing separate buttons. With both
|
|
20
|
+
* `camera` and `gallery` the user is asked which one; with one, it opens directly. Needs `dragDrop`.
|
|
21
|
+
*/
|
|
22
|
+
clickable?: boolean
|
|
23
|
+
/** How a touch-screen user is asked "camera or gallery" with `clickable`: a bottom sheet or a centered modal. */
|
|
24
|
+
chooser?: "sheet" | "modal"
|
|
16
25
|
/** Same format as `<input accept>`. Also enforced on dropped files. */
|
|
17
26
|
accept?: string
|
|
18
27
|
multiple?: boolean
|
|
@@ -49,6 +58,8 @@ function FileUpload({
|
|
|
49
58
|
camera = false,
|
|
50
59
|
gallery = false,
|
|
51
60
|
dragDrop = false,
|
|
61
|
+
clickable = false,
|
|
62
|
+
chooser = "sheet",
|
|
52
63
|
accept = "image/*",
|
|
53
64
|
multiple = true,
|
|
54
65
|
maxFiles,
|
|
@@ -68,6 +79,8 @@ function FileUpload({
|
|
|
68
79
|
const [dragging, setDragging] = React.useState(false)
|
|
69
80
|
const cameraRef = React.useRef<HTMLInputElement>(null)
|
|
70
81
|
const galleryRef = React.useRef<HTMLInputElement>(null)
|
|
82
|
+
const [askOpen, setAskOpen] = React.useState(false)
|
|
83
|
+
const pointerType = React.useRef("")
|
|
71
84
|
|
|
72
85
|
// Created and revoked in the same effect: with useMemo, Strict Mode's
|
|
73
86
|
// simulated unmount revoked the URLs and the remount reused the dead ones.
|
|
@@ -112,6 +125,37 @@ function FileUpload({
|
|
|
112
125
|
|
|
113
126
|
if (!camera && !gallery && !dragDrop) return null
|
|
114
127
|
|
|
128
|
+
// Pressing the drop zone only does something if there's a picker to open.
|
|
129
|
+
const pickable = clickable && !disabled && (camera || gallery)
|
|
130
|
+
// The camera only means something on touch devices, so mouse and keyboard skip the
|
|
131
|
+
// question and open the gallery (or the only source there is).
|
|
132
|
+
const openDefault = () => (gallery ? galleryRef : cameraRef).current?.click()
|
|
133
|
+
const units = multiple ? "files" : "a file"
|
|
134
|
+
|
|
135
|
+
const askTitle = `Add ${multiple ? "photos" : "a photo"}`
|
|
136
|
+
const askDescription = "Take a new photo or pick from your gallery."
|
|
137
|
+
const askButtons = (
|
|
138
|
+
<div className="grid gap-2">
|
|
139
|
+
{[
|
|
140
|
+
{ ref: cameraRef, Icon: Camera, text: "Take photo" },
|
|
141
|
+
{ ref: galleryRef, Icon: ImagePlus, text: "Choose from gallery" },
|
|
142
|
+
].map(({ ref, Icon, text }) => (
|
|
143
|
+
<Button
|
|
144
|
+
key={text}
|
|
145
|
+
type="button"
|
|
146
|
+
variant="outline"
|
|
147
|
+
className="h-12 justify-start"
|
|
148
|
+
onClick={() => {
|
|
149
|
+
setAskOpen(false)
|
|
150
|
+
ref.current?.click()
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<Icon /> {text}
|
|
154
|
+
</Button>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
|
|
115
159
|
const buttons = (
|
|
116
160
|
<div className="flex flex-wrap justify-center gap-2">
|
|
117
161
|
{camera && (
|
|
@@ -138,6 +182,28 @@ function FileUpload({
|
|
|
138
182
|
|
|
139
183
|
{dragDrop ? (
|
|
140
184
|
<div
|
|
185
|
+
role={pickable ? "button" : undefined}
|
|
186
|
+
tabIndex={pickable ? 0 : undefined}
|
|
187
|
+
onPointerDown={pickable ? (e) => (pointerType.current = e.pointerType) : undefined}
|
|
188
|
+
onClick={
|
|
189
|
+
pickable
|
|
190
|
+
? () => {
|
|
191
|
+
if (camera && gallery && pointerType.current !== "mouse") setAskOpen(true)
|
|
192
|
+
else openDefault()
|
|
193
|
+
pointerType.current = ""
|
|
194
|
+
}
|
|
195
|
+
: undefined
|
|
196
|
+
}
|
|
197
|
+
onKeyDown={
|
|
198
|
+
pickable
|
|
199
|
+
? (e) => {
|
|
200
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
201
|
+
e.preventDefault()
|
|
202
|
+
openDefault()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
: undefined
|
|
206
|
+
}
|
|
141
207
|
onDragOver={(e) => {
|
|
142
208
|
e.preventDefault()
|
|
143
209
|
if (!disabled) setDragging(true)
|
|
@@ -151,6 +217,7 @@ function FileUpload({
|
|
|
151
217
|
aria-disabled={disabled}
|
|
152
218
|
className={cn(
|
|
153
219
|
"flex flex-col items-center gap-3 rounded-lg border-2 border-dashed p-6 text-center text-sm text-muted-foreground transition-colors",
|
|
220
|
+
pickable && "cursor-pointer outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring",
|
|
154
221
|
// Before the state classes so a custom border can't hide the drag highlight.
|
|
155
222
|
dropzoneClassName,
|
|
156
223
|
dragging && "border-primary bg-primary/5",
|
|
@@ -159,15 +226,49 @@ function FileUpload({
|
|
|
159
226
|
>
|
|
160
227
|
{icon}
|
|
161
228
|
<div className="space-y-1">
|
|
162
|
-
<p className={labelClassName}>
|
|
229
|
+
<p className={labelClassName}>
|
|
230
|
+
{label ??
|
|
231
|
+
(pickable ? (
|
|
232
|
+
<>
|
|
233
|
+
<span className="pointer-coarse:hidden">Drag and drop {units} here, or click to browse</span>
|
|
234
|
+
<span className="hidden pointer-coarse:inline">Tap to add {units}</span>
|
|
235
|
+
</>
|
|
236
|
+
) : (
|
|
237
|
+
`Drag and drop ${units} here`
|
|
238
|
+
))}
|
|
239
|
+
</p>
|
|
163
240
|
{description && <p className={cn("text-xs", descriptionClassName)}>{description}</p>}
|
|
164
241
|
</div>
|
|
165
|
-
{buttons}
|
|
242
|
+
{!pickable && buttons}
|
|
166
243
|
</div>
|
|
167
244
|
) : (
|
|
168
245
|
buttons
|
|
169
246
|
)}
|
|
170
247
|
|
|
248
|
+
{pickable && camera && gallery && (
|
|
249
|
+
chooser === "modal" ? (
|
|
250
|
+
<Dialog open={askOpen} onOpenChange={setAskOpen}>
|
|
251
|
+
<DialogContent className="sm:max-w-sm">
|
|
252
|
+
<DialogHeader>
|
|
253
|
+
<DialogTitle>{askTitle}</DialogTitle>
|
|
254
|
+
<DialogDescription>{askDescription}</DialogDescription>
|
|
255
|
+
</DialogHeader>
|
|
256
|
+
{askButtons}
|
|
257
|
+
</DialogContent>
|
|
258
|
+
</Dialog>
|
|
259
|
+
) : (
|
|
260
|
+
<Sheet open={askOpen} onOpenChange={setAskOpen}>
|
|
261
|
+
<SheetContent side="bottom" className="mx-auto max-w-lg rounded-t-xl pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
262
|
+
<SheetHeader className="pb-0">
|
|
263
|
+
<SheetTitle>{askTitle}</SheetTitle>
|
|
264
|
+
<SheetDescription>{askDescription}</SheetDescription>
|
|
265
|
+
</SheetHeader>
|
|
266
|
+
<div className="px-4">{askButtons}</div>
|
|
267
|
+
</SheetContent>
|
|
268
|
+
</Sheet>
|
|
269
|
+
)
|
|
270
|
+
)}
|
|
271
|
+
|
|
171
272
|
{error && (
|
|
172
273
|
<p role="alert" className="text-sm text-destructive">
|
|
173
274
|
{error}
|