image-url-upload.tsx
6.59 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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
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;
/** 空状态主文案(默认无,仅加号;需要时传入如 "Click to upload") */
emptyLabel?: string;
accept?: string;
/** 默认 5MB,与平台 picture 上传接口一致 */
maxSizeMb?: number;
className?: string;
/** 上传区域宽度(tailwind),默认 max-w-[200px] */
boxClassName?: string;
/** 固定上传区边长(px);优先于仅依赖 Tailwind 的 boxClassName,避免预构建 CSS 缺类名时撑满整行 */
boxSizePx?: number;
/** 传给后端的 multipart `subDir`(如 category、product) */
uploadSubDir?: string;
/** 明确单图:隐藏多选、提示文案 */
oneImageOnly?: boolean;
};
export function ImageUrlUpload({
value,
onChange,
disabled,
hint,
emptyLabel = "",
accept = "image/jpeg,image/png,image/webp,image/gif",
maxSizeMb = PICTURE_UPLOAD_MAX_BYTES / (1024 * 1024),
className,
boxClassName,
boxSizePx,
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 fixedSize = typeof boxSizePx === "number" && boxSizePx > 0 ? Math.round(boxSizePx) : 0;
const hasCustomBox = fixedSize > 0 || Boolean(boxClassName?.trim());
const boxSizeStyle: React.CSSProperties | undefined =
fixedSize > 0
? {
width: fixedSize,
height: fixedSize,
minWidth: fixedSize,
minHeight: fixedSize,
maxWidth: fixedSize,
maxHeight: fixedSize,
boxSizing: "border-box",
}
: undefined;
const boxShell = hasCustomBox
? "box-border shrink-0 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
: "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", hasCustomBox && "w-fit max-w-full", 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}
aria-label={emptyLabel || "Upload image"}
style={boxSizeStyle}
className={cn(
boxShell,
hasCustomBox ? boxClassName : null,
"flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400",
emptyLabel && !uploading
? "flex-col items-center justify-center gap-2"
: "items-center justify-center",
"hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
{uploading ? (
<span className="px-3 text-center text-sm font-normal text-gray-500">Uploading…</span>
) : (
<>
<Plus
className={cn(
"shrink-0 stroke-[1.25]",
fixedSize > 0 && fixedSize <= 120 ? "h-8 w-8" : "h-10 w-10",
)}
aria-hidden
/>
{emptyLabel ? (
<span className="px-3 text-center text-sm font-normal leading-tight text-gray-400">
{emptyLabel}
</span>
) : null}
</>
)}
</button>
) : (
<div
style={boxSizeStyle}
className={cn(
"group relative overflow-hidden border-2 border-dashed border-gray-300 bg-gray-50/80",
boxShell,
hasCustomBox ? boxClassName : null,
)}
>
<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>
);
}