image-url-upload.tsx
5.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import React, { useRef, useState } from "react";
import { Plus, X } from "lucide-react";
import { toast } from "sonner";
import { cn } from "./utils";
import { PICTURE_UPLOAD_MAX_BYTES, resolvePictureUrlForDisplay, uploadImageFile } from "../../services/imageUploadService";
export type ImageUrlUploadProps = {
value: string;
onChange: (url: string) => void;
disabled?: boolean;
/** 辅助说明,显示在方框下方 */
hint?: string;
/** 空状态主文案 */
emptyLabel?: string;
accept?: string;
/** 默认 5MB,与平台 picture 上传接口一致 */
maxSizeMb?: number;
className?: string;
/** 上传区域宽度(tailwind),默认 max-w-[200px] */
boxClassName?: string;
/** 传给后端的 multipart `subDir`(如 category、product) */
uploadSubDir?: string;
/** 明确单图:隐藏多选、提示文案 */
oneImageOnly?: boolean;
};
export function ImageUrlUpload({
value,
onChange,
disabled,
hint,
emptyLabel = "Click to upload image",
accept = "image/jpeg,image/png,image/webp,image/gif",
maxSizeMb = PICTURE_UPLOAD_MAX_BYTES / (1024 * 1024),
className,
boxClassName,
uploadSubDir,
oneImageOnly,
}: ImageUrlUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Please select an image file.");
return;
}
if (file.size > maxSizeMb * 1024 * 1024) {
toast.error(`Image must be ${maxSizeMb} MB or smaller.`);
return;
}
setUploading(true);
try {
const url = await uploadImageFile(file, { subDir: uploadSubDir });
onChange(url);
toast.success("Image uploaded.");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
toast.error("Upload failed", { description: msg || undefined });
} finally {
setUploading(false);
}
};
const busy = disabled || uploading;
const openPicker = () => {
if (!busy) inputRef.current?.click();
};
const boxBase =
"w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2";
return (
<div className={cn("space-y-2", className)}>
<input
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
disabled={busy}
multiple={false}
onChange={onFileChange}
/>
{!value ? (
<button
type="button"
disabled={busy}
onClick={openPicker}
className={cn(
boxBase,
"flex flex-col items-center justify-center gap-3 border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400",
"hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500",
"disabled:pointer-events-none disabled:opacity-50",
boxClassName,
)}
>
<Plus className="h-10 w-10 shrink-0 stroke-[1.25]" aria-hidden />
<span className="px-3 text-center text-sm font-normal leading-tight">
{uploading ? "Uploading…" : emptyLabel}
</span>
</button>
) : (
<div
className={cn(
"group relative overflow-hidden rounded-md border-2 border-dashed border-gray-300 bg-gray-50/80",
boxBase,
boxClassName,
)}
>
<button
type="button"
disabled={busy}
onClick={openPicker}
className="relative h-full w-full p-0"
aria-label="Replace image"
>
<img
src={resolvePictureUrlForDisplay(value)}
alt=""
className="h-full w-full object-contain"
onError={(ev) => {
(ev.target as HTMLImageElement).style.opacity = "0.2";
}}
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-black/45 group-hover:opacity-100">
Click to replace
</span>
</button>
<button
type="button"
disabled={busy}
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-white/95 text-gray-600 shadow-sm ring-1 ring-gray-200 transition hover:bg-white hover:text-gray-900 disabled:opacity-50"
aria-label="Remove image"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{oneImageOnly ? (
<p className="text-xs text-muted-foreground">One image only. Replace or clear to change.</p>
) : null}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}