@rxdrag/website-lib-core 0.0.49 → 0.0.51
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/package.json +10 -9
- package/src/astro/README.md +1 -0
- package/src/astro/animation.ts +146 -0
- package/src/astro/background.ts +53 -0
- package/src/astro/grid/consts.ts +80 -0
- package/src/astro/grid/index.ts +2 -0
- package/src/astro/grid/types.ts +35 -0
- package/src/astro/index.ts +7 -0
- package/src/astro/media.ts +109 -0
- package/src/astro/section/index.ts +12 -0
- package/src/entify/Entify.ts +32 -2
- package/src/entify/IEntify.ts +30 -6
- package/src/entify/lib/createUploadCredentials.ts +56 -0
- package/src/entify/lib/index.ts +30 -29
- package/src/entify/lib/newQueryProductOptions.ts +1 -0
- package/src/entify/lib/queryLangs.ts +5 -5
- package/src/entify/lib/queryLatestPosts.ts +9 -1
- package/src/entify/lib/queryOneTheme.ts +12 -12
- package/src/index.ts +1 -0
- package/src/react/components/Analytics/eventHandlers.ts +173 -0
- package/src/react/components/Analytics/index.tsx +21 -0
- package/src/react/components/Analytics/singleton.ts +214 -0
- package/src/react/components/Analytics/tracking.ts +221 -0
- package/src/react/components/Analytics/types.ts +60 -0
- package/src/react/components/Analytics/utils.ts +95 -0
- package/src/react/components/BackgroundHlsVideoPlayer.tsx +68 -0
- package/src/react/components/BackgroundVideoPlayer.tsx +32 -0
- package/src/react/components/ContactForm/ContactForm.tsx +286 -0
- package/src/react/components/ContactForm/FileUpload.tsx +430 -0
- package/src/react/components/ContactForm/Input.tsx +6 -10
- package/src/react/components/ContactForm/Input2.tsx +64 -0
- package/src/react/components/ContactForm/Submit.tsx +25 -10
- package/src/react/components/ContactForm/Textarea.tsx +7 -10
- package/src/react/components/ContactForm/Textarea2.tsx +64 -0
- package/src/react/components/ContactForm/factory.tsx +49 -0
- package/src/react/components/ContactForm/funcs.ts +64 -0
- package/src/react/components/ContactForm/index.ts +7 -0
- package/src/react/components/ContactForm/types.ts +67 -0
- package/src/react/components/ContactForm/useVisitorInfo.ts +31 -0
- package/src/react/components/index.ts +3 -0
- package/src/react/components/ContactForm/index.tsx +0 -351
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { forwardRef, useState } from "react";
|
|
2
|
+
import { Submit } from "./Submit";
|
|
3
|
+
import { type UploadedFile } from "./FileUpload";
|
|
4
|
+
import clsx from "clsx";
|
|
5
|
+
import { modal } from "../../../controller";
|
|
6
|
+
import { encrypt } from "./funcs";
|
|
7
|
+
import { ContactFormProps, QuoteRequest } from "./types";
|
|
8
|
+
import { getControl, getFileUpload } from "./factory";
|
|
9
|
+
|
|
10
|
+
interface FormErrors {
|
|
11
|
+
name?: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ContactForm = forwardRef<HTMLDivElement, ContactFormProps>(
|
|
17
|
+
(props, ref) => {
|
|
18
|
+
const {
|
|
19
|
+
submit,
|
|
20
|
+
actionUrl = "/api/ask-for-quote",
|
|
21
|
+
formSalt,
|
|
22
|
+
fields = [],
|
|
23
|
+
className,
|
|
24
|
+
classNames,
|
|
25
|
+
} = props;
|
|
26
|
+
const [formData, setFormData] = useState<QuoteRequest>({
|
|
27
|
+
name: "",
|
|
28
|
+
email: "",
|
|
29
|
+
company: "",
|
|
30
|
+
message: "",
|
|
31
|
+
phone: "", // 初始化蜜罐字段
|
|
32
|
+
attachments: [], // 附件 ID 列表
|
|
33
|
+
});
|
|
34
|
+
// 错误状态
|
|
35
|
+
const [errors, setErrors] = useState<FormErrors>({});
|
|
36
|
+
const [submitting, setSubmitting] = useState(false);
|
|
37
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
38
|
+
const [submitStatus, setSubmitStatus] = useState<{
|
|
39
|
+
success?: boolean;
|
|
40
|
+
message?: string;
|
|
41
|
+
}>({});
|
|
42
|
+
|
|
43
|
+
// 处理输入变化
|
|
44
|
+
const handleChange = (
|
|
45
|
+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
46
|
+
isExtends?: boolean
|
|
47
|
+
) => {
|
|
48
|
+
const { name, value } = e.target;
|
|
49
|
+
if (isExtends) {
|
|
50
|
+
setFormData((prev) => ({
|
|
51
|
+
...prev,
|
|
52
|
+
extents: {
|
|
53
|
+
...(prev.extends || {}),
|
|
54
|
+
[name]: value,
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
} else {
|
|
58
|
+
setFormData((prev) => ({
|
|
59
|
+
...prev,
|
|
60
|
+
[name]: value,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setSubmitStatus({}); // 重置提交状态
|
|
65
|
+
|
|
66
|
+
// 清除对应字段的错误
|
|
67
|
+
if (errors[name as keyof FormErrors]) {
|
|
68
|
+
setErrors((prev) => ({
|
|
69
|
+
...prev,
|
|
70
|
+
[name]: undefined,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// 处理文件上传变化(单文件)
|
|
76
|
+
const handleFileChange = (fileInfo: UploadedFile | null) => {
|
|
77
|
+
setFormData((prev) => ({
|
|
78
|
+
...prev,
|
|
79
|
+
attachments: fileInfo ? [fileInfo] : [], // 存储完整的文件信息
|
|
80
|
+
}));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// 处理文件上传状态变化
|
|
84
|
+
const handleFileUploadStateChange = (uploading: boolean) => {
|
|
85
|
+
setIsUploading(uploading);
|
|
86
|
+
};
|
|
87
|
+
// 验证表单
|
|
88
|
+
const validateForm = (): boolean => {
|
|
89
|
+
const newErrors: FormErrors = {};
|
|
90
|
+
|
|
91
|
+
// if (!formData.name?.trim()) {
|
|
92
|
+
// newErrors.name = "Please enter your name";
|
|
93
|
+
// }
|
|
94
|
+
|
|
95
|
+
if (!formData.email?.trim()) {
|
|
96
|
+
newErrors.email = "Please enter your email";
|
|
97
|
+
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
98
|
+
newErrors.email = "Please enter a valid email address";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!formData.message?.trim()) {
|
|
102
|
+
newErrors.message = "Please enter your message";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 检查蜜罐字段 - 如果填写了则表示是机器人
|
|
106
|
+
if (formData.phone) {
|
|
107
|
+
// 悄悄失败,不显示错误信息
|
|
108
|
+
console.log("Honeypot triggered - likely spam submission");
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setErrors(newErrors);
|
|
113
|
+
return Object.keys(newErrors).length === 0;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleClick = async (event: React.MouseEvent) => {
|
|
117
|
+
event.preventDefault(); // 阻止表单默认提交行为
|
|
118
|
+
|
|
119
|
+
// 验证表单
|
|
120
|
+
if (!validateForm()) {
|
|
121
|
+
return; // 如果验证失败,不继续提交
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
setSubmitting(true);
|
|
126
|
+
setSubmitStatus({}); // 重置提交状态
|
|
127
|
+
const response = await fetch(actionUrl, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
...formData,
|
|
131
|
+
fromCta: modal.lastCta,
|
|
132
|
+
encryptedField: encrypt(formData.phone, formSalt),
|
|
133
|
+
}),
|
|
134
|
+
headers: {
|
|
135
|
+
"X-Request-URL": window.location.href,
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error(`Server responded with status: ${response.status}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = await response.json();
|
|
145
|
+
|
|
146
|
+
setSubmitStatus({
|
|
147
|
+
success: result.success,
|
|
148
|
+
message: result.message,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (result.success) {
|
|
152
|
+
// 重置表单
|
|
153
|
+
setFormData({
|
|
154
|
+
name: "",
|
|
155
|
+
email: "",
|
|
156
|
+
company: "",
|
|
157
|
+
message: "",
|
|
158
|
+
phone: "",
|
|
159
|
+
attachments: [],
|
|
160
|
+
});
|
|
161
|
+
window.location.href = "/thanks";
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// 如果出现错误,打印错误信息
|
|
165
|
+
console.error("Form submission error:", error);
|
|
166
|
+
setSubmitStatus({
|
|
167
|
+
success: false,
|
|
168
|
+
message:
|
|
169
|
+
error instanceof Error
|
|
170
|
+
? `Error: ${error.message}`
|
|
171
|
+
: "Failed to submit the form. Please try again later.",
|
|
172
|
+
});
|
|
173
|
+
} finally {
|
|
174
|
+
setSubmitting(false);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
ref={ref}
|
|
181
|
+
className={clsx(
|
|
182
|
+
"py-4 grid max-w-2xl grid-cols-1 gap-x-6 sm:grid-cols-2",
|
|
183
|
+
className || "gap-y-6"
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{fields.map((field, index) => {
|
|
187
|
+
const {
|
|
188
|
+
controlName,
|
|
189
|
+
feildStyle,
|
|
190
|
+
isExtends,
|
|
191
|
+
name,
|
|
192
|
+
className,
|
|
193
|
+
inputClassName,
|
|
194
|
+
labelClassName,
|
|
195
|
+
...rest
|
|
196
|
+
} = field;
|
|
197
|
+
|
|
198
|
+
// FileUpload 组件特殊处理
|
|
199
|
+
if (controlName === "FileUpload") {
|
|
200
|
+
const FileUploadControl = getFileUpload(feildStyle);
|
|
201
|
+
return (
|
|
202
|
+
<FileUploadControl
|
|
203
|
+
key={name || index}
|
|
204
|
+
name={name || "attachments"}
|
|
205
|
+
formSalt={formSalt}
|
|
206
|
+
className={clsx(classNames?.inputContainer, className)}
|
|
207
|
+
inputClassName={clsx(classNames?.input, inputClassName)}
|
|
208
|
+
labelClassName={clsx(classNames?.label, labelClassName)}
|
|
209
|
+
maxSize={3 * 1024 * 1024}
|
|
210
|
+
onChange={handleFileChange}
|
|
211
|
+
onUploadStateChange={handleFileUploadStateChange}
|
|
212
|
+
placeholder="Click to select file"
|
|
213
|
+
uploadingText="Uploading..."
|
|
214
|
+
selectedText="Selected: {name}"
|
|
215
|
+
accept="*/*"
|
|
216
|
+
{...rest}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 普通输入控件
|
|
222
|
+
const Control = getControl(controlName, feildStyle);
|
|
223
|
+
return (
|
|
224
|
+
<Control
|
|
225
|
+
key={name || index}
|
|
226
|
+
name={name}
|
|
227
|
+
className={clsx(classNames?.inputContainer, className)}
|
|
228
|
+
labelClassName={clsx(classNames?.label, labelClassName)}
|
|
229
|
+
inputClassName={clsx(classNames?.input, inputClassName)}
|
|
230
|
+
requiredClassName={classNames?.required}
|
|
231
|
+
value={formData[name as keyof QuoteRequest] as string}
|
|
232
|
+
onChange={(e) => handleChange(e, isExtends)}
|
|
233
|
+
error={errors[name as keyof FormErrors]}
|
|
234
|
+
{...rest}
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
|
|
239
|
+
{/* 蜜罐字段 - 对用户隐藏但对机器人可见 */}
|
|
240
|
+
<div style={{ display: "none" }}>
|
|
241
|
+
<input
|
|
242
|
+
type="text"
|
|
243
|
+
name="phone"
|
|
244
|
+
value={formData.phone}
|
|
245
|
+
onChange={handleChange}
|
|
246
|
+
tabIndex={-1}
|
|
247
|
+
autoComplete="off"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
<div
|
|
251
|
+
className={clsx(
|
|
252
|
+
"col-span-full flex items-center gap-x-6 px-0 py-2",
|
|
253
|
+
submit?.containerClassName
|
|
254
|
+
)}
|
|
255
|
+
>
|
|
256
|
+
{submitStatus.message && !submitStatus.success && (
|
|
257
|
+
<div
|
|
258
|
+
className={`text-sm ${
|
|
259
|
+
submitStatus.success ? "text-green-600" : "text-red-600"
|
|
260
|
+
}`}
|
|
261
|
+
>
|
|
262
|
+
{submitStatus.message}
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
<Submit
|
|
266
|
+
className={clsx(
|
|
267
|
+
"flex gap-2 items-center relative shadow-sm btn btn-lg nowrap",
|
|
268
|
+
submit?.className || "btn-primary"
|
|
269
|
+
)}
|
|
270
|
+
title={submit?.title || "Send Message"}
|
|
271
|
+
spinner={
|
|
272
|
+
<div className="left-8 flex items-center justify-center">
|
|
273
|
+
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white" />
|
|
274
|
+
</div>
|
|
275
|
+
}
|
|
276
|
+
rawHtml={submit?.rawHtml}
|
|
277
|
+
needClasses={submit?.needClasses}
|
|
278
|
+
submitting={submitting}
|
|
279
|
+
disabled={submitting || isUploading}
|
|
280
|
+
onClick={handleClick}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
);
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import { forwardRef, useRef, useState, useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Simple encryption function for generating anti-bot encrypted fields
|
|
6
|
+
*/
|
|
7
|
+
const generateEncryption = (formSalt: string, value: string): string => {
|
|
8
|
+
const timestamp = Math.floor(Date.now() / (60 * 1000));
|
|
9
|
+
const dataToEncrypt = `${formSalt}:${timestamp}:${value}`;
|
|
10
|
+
|
|
11
|
+
let hash = 0;
|
|
12
|
+
for (let i = 0; i < dataToEncrypt.length; i++) {
|
|
13
|
+
const char = dataToEncrypt.charCodeAt(i);
|
|
14
|
+
hash = (hash << 5) - hash + char;
|
|
15
|
+
hash = hash & hash;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return `${hash.toString(16)}_${timestamp}`;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type UploadedFile = {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
size: number;
|
|
25
|
+
url: string; // OSS public URL
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type FileUploadProps = {
|
|
29
|
+
label: string;
|
|
30
|
+
name: string;
|
|
31
|
+
required?: boolean;
|
|
32
|
+
className?: string;
|
|
33
|
+
labelClassName?: string;
|
|
34
|
+
inputClassName?: string;
|
|
35
|
+
accept?: string;
|
|
36
|
+
onChange?: (fileInfo: UploadedFile | null) => void;
|
|
37
|
+
onUploadStateChange?: (isUploading: boolean) => void;
|
|
38
|
+
error?: string;
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
uploadingText?: string;
|
|
41
|
+
selectedText?: string;
|
|
42
|
+
formSalt?: string; // 加密盐
|
|
43
|
+
maxSize?: number; // 最大文件大小(字节),默认 5MB
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
|
|
47
|
+
(props, ref) => {
|
|
48
|
+
const {
|
|
49
|
+
label,
|
|
50
|
+
name,
|
|
51
|
+
required,
|
|
52
|
+
className,
|
|
53
|
+
labelClassName,
|
|
54
|
+
inputClassName,
|
|
55
|
+
accept = "*/*",
|
|
56
|
+
onChange,
|
|
57
|
+
onUploadStateChange,
|
|
58
|
+
error,
|
|
59
|
+
placeholder = "Click to select file",
|
|
60
|
+
uploadingText = "Uploading...",
|
|
61
|
+
selectedText = "Selected: {name}",
|
|
62
|
+
formSalt = "default-salt",
|
|
63
|
+
maxSize = 5 * 1024 * 1024,
|
|
64
|
+
...rest
|
|
65
|
+
} = props;
|
|
66
|
+
|
|
67
|
+
const [uploadedFile, setUploadedFile] = useState<UploadedFile | null>(null);
|
|
68
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
69
|
+
const [uploadError, setUploadError] = useState<string>("");
|
|
70
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
71
|
+
const labelRef = useRef<HTMLLabelElement>(null);
|
|
72
|
+
const [paddingLeft, setPaddingLeft] = useState("3.5rem");
|
|
73
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (labelRef.current) {
|
|
77
|
+
const width = labelRef.current.getBoundingClientRect().width;
|
|
78
|
+
setPaddingLeft(`${width + 16}px`);
|
|
79
|
+
}
|
|
80
|
+
}, [label]);
|
|
81
|
+
|
|
82
|
+
// Upload single file
|
|
83
|
+
const uploadFile = async (
|
|
84
|
+
file: File,
|
|
85
|
+
signal: AbortSignal
|
|
86
|
+
): Promise<UploadedFile> => {
|
|
87
|
+
// Validate file size
|
|
88
|
+
if (file.size > maxSize) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`File ${file.name} exceeds maximum size limit of ${
|
|
91
|
+
maxSize / 1024 / 1024
|
|
92
|
+
}MB`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Generate encrypted field with honeypot value
|
|
97
|
+
const honeypot = "";
|
|
98
|
+
const encryptedField = generateEncryption(formSalt, honeypot);
|
|
99
|
+
|
|
100
|
+
// 1. Get upload credentials
|
|
101
|
+
const credentialsResponse = await fetch("/api/get-upload-credentials", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
fileName: file.name,
|
|
108
|
+
fileSize: file.size,
|
|
109
|
+
fileType: file.type,
|
|
110
|
+
mediaType: "document", // Request document type credentials (usually R2)
|
|
111
|
+
encryptedField,
|
|
112
|
+
honeypot,
|
|
113
|
+
}),
|
|
114
|
+
signal,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!credentialsResponse.ok) {
|
|
118
|
+
const errorData = await credentialsResponse.json();
|
|
119
|
+
throw new Error(
|
|
120
|
+
errorData.message || "Failed to get upload credentials"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const credentialsData = await credentialsResponse.json();
|
|
125
|
+
|
|
126
|
+
if (!credentialsData.success) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
credentialsData.message || "Failed to get upload credentials"
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Extract upload information from response
|
|
133
|
+
const { credentials, mediaType } = credentialsData;
|
|
134
|
+
const uploadUrl = credentials?.uploadUrl;
|
|
135
|
+
const platform = credentials?.platform;
|
|
136
|
+
|
|
137
|
+
if (!uploadUrl) {
|
|
138
|
+
throw new Error("Failed to get upload URL");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Upload to cloud storage
|
|
142
|
+
|
|
143
|
+
let uploadResponse: Response;
|
|
144
|
+
|
|
145
|
+
// Use different upload methods based on platform type
|
|
146
|
+
if (platform === "cloudflare_images") {
|
|
147
|
+
// Cloudflare Images uses FormData + POST
|
|
148
|
+
const formData = new FormData();
|
|
149
|
+
formData.append("file", file);
|
|
150
|
+
|
|
151
|
+
uploadResponse = await fetch(uploadUrl, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
body: formData,
|
|
154
|
+
signal,
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
// Other platforms use PUT (e.g., pre-signed URLs)
|
|
158
|
+
uploadResponse = await fetch(uploadUrl, {
|
|
159
|
+
method: "PUT",
|
|
160
|
+
headers: {
|
|
161
|
+
"Content-Type": file.type,
|
|
162
|
+
},
|
|
163
|
+
body: file,
|
|
164
|
+
signal,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!uploadResponse.ok) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`File upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Get resource identifier after upload
|
|
175
|
+
let resourceId: string;
|
|
176
|
+
let publicUrl: string;
|
|
177
|
+
|
|
178
|
+
if (platform === "cloudflare_images") {
|
|
179
|
+
// Cloudflare Images returns JSON, extract result.id
|
|
180
|
+
const uploadResult = await uploadResponse.json();
|
|
181
|
+
resourceId = uploadResult?.result?.id || uploadUrl;
|
|
182
|
+
// Construct Cloudflare Images public URL
|
|
183
|
+
// Format: https://imagedelivery.net/{account_hash}/{image_id}/public
|
|
184
|
+
const accountHash = uploadUrl.split("/")[3]; // Extract from upload URL
|
|
185
|
+
publicUrl = `https://imagedelivery.net/${accountHash}/${resourceId}/public`;
|
|
186
|
+
} else if (platform === "cloudflare_r2") {
|
|
187
|
+
// Cloudflare R2: uploadUrl is already the public URL (pre-signed or public bucket URL)
|
|
188
|
+
resourceId = uploadUrl.split("/").pop() || uploadUrl; // Extract filename from URL
|
|
189
|
+
publicUrl = uploadUrl; // Use the upload URL as public URL
|
|
190
|
+
} else {
|
|
191
|
+
// Other platforms use uploadUrl as resource identifier
|
|
192
|
+
resourceId = uploadUrl;
|
|
193
|
+
publicUrl = uploadUrl;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 3. Insert Media object into database
|
|
197
|
+
const mediaPayload = {
|
|
198
|
+
name: file.name,
|
|
199
|
+
size: file.size,
|
|
200
|
+
mimeType: file.type,
|
|
201
|
+
mediaType,
|
|
202
|
+
resourceUrl: resourceId,
|
|
203
|
+
storageType: platform || "cloudflare_images",
|
|
204
|
+
encryptedField,
|
|
205
|
+
honeypot,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const createMediaResponse = await fetch("/api/create-media", {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
"Content-Type": "application/json",
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify(mediaPayload),
|
|
214
|
+
signal,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!createMediaResponse.ok) {
|
|
218
|
+
const errorData = await createMediaResponse.json();
|
|
219
|
+
throw new Error(errorData.message || "Failed to create Media object");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const mediaData = await createMediaResponse.json();
|
|
223
|
+
|
|
224
|
+
if (!mediaData.success) {
|
|
225
|
+
throw new Error(mediaData.message || "Failed to create Media object");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Return uploaded file information
|
|
229
|
+
return {
|
|
230
|
+
id: mediaData.mediaId,
|
|
231
|
+
name: file.name,
|
|
232
|
+
size: file.size,
|
|
233
|
+
url: publicUrl, // Use public URL instead of resource ID
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
238
|
+
const files = e.target.files;
|
|
239
|
+
|
|
240
|
+
if (!files || files.length === 0) return;
|
|
241
|
+
|
|
242
|
+
const file = files[0]; // Only take the first file
|
|
243
|
+
|
|
244
|
+
// Create new AbortController
|
|
245
|
+
const abortController = new AbortController();
|
|
246
|
+
abortControllerRef.current = abortController;
|
|
247
|
+
|
|
248
|
+
setIsUploading(true);
|
|
249
|
+
onUploadStateChange?.(true); // 通知父组件开始上传
|
|
250
|
+
setUploadError("");
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
// Validate file size
|
|
254
|
+
if (file.size > maxSize) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`File ${file.name} exceeds ${maxSize / 1024 / 1024}MB limit`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Upload file
|
|
261
|
+
const uploadedFileData = await uploadFile(file, abortController.signal);
|
|
262
|
+
setUploadedFile(uploadedFileData);
|
|
263
|
+
onChange?.(uploadedFileData); // Pass complete file info
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// Don't show error if user cancelled
|
|
266
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
267
|
+
console.log("User cancelled upload");
|
|
268
|
+
} else {
|
|
269
|
+
console.error("File upload failed:", error);
|
|
270
|
+
setUploadError(
|
|
271
|
+
error instanceof Error ? error.message : "File upload failed"
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
setIsUploading(false);
|
|
276
|
+
onUploadStateChange?.(false); // 通知父组件上传结束
|
|
277
|
+
abortControllerRef.current = null;
|
|
278
|
+
// Clear input to allow re-selecting the same file
|
|
279
|
+
if (fileInputRef.current) {
|
|
280
|
+
fileInputRef.current.value = "";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const handleClick = () => {
|
|
286
|
+
// Don't allow selection if uploading or file already exists
|
|
287
|
+
if (isUploading || uploadedFile) return;
|
|
288
|
+
fileInputRef.current?.click();
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleRemoveFile = (e: React.MouseEvent) => {
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
setUploadedFile(null);
|
|
294
|
+
onChange?.(null); // Pass null when file is removed
|
|
295
|
+
setUploadError("");
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const handleCancelUpload = (e: React.MouseEvent) => {
|
|
299
|
+
e.stopPropagation();
|
|
300
|
+
if (abortControllerRef.current) {
|
|
301
|
+
abortControllerRef.current.abort();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const getDisplayText = () => {
|
|
306
|
+
if (isUploading) return uploadingText;
|
|
307
|
+
if (uploadedFile) {
|
|
308
|
+
return selectedText.replace("{name}", uploadedFile.name);
|
|
309
|
+
}
|
|
310
|
+
return placeholder;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div
|
|
315
|
+
ref={ref}
|
|
316
|
+
className={clsx("relative w-full", className)}
|
|
317
|
+
{...rest}
|
|
318
|
+
>
|
|
319
|
+
<label
|
|
320
|
+
ref={labelRef}
|
|
321
|
+
htmlFor={name}
|
|
322
|
+
className={clsx(
|
|
323
|
+
"absolute left-3 top-1/2 -translate-y-1/2 mt-1 text-gray-600 text-sm pointer-events-none whitespace-nowrap z-10",
|
|
324
|
+
labelClassName
|
|
325
|
+
)}
|
|
326
|
+
>
|
|
327
|
+
{label}
|
|
328
|
+
</label>
|
|
329
|
+
|
|
330
|
+
{/* Hidden file input */}
|
|
331
|
+
<input
|
|
332
|
+
ref={fileInputRef}
|
|
333
|
+
type="file"
|
|
334
|
+
id={name}
|
|
335
|
+
name={name}
|
|
336
|
+
required={required}
|
|
337
|
+
accept={accept}
|
|
338
|
+
onChange={handleFileChange}
|
|
339
|
+
disabled={!!uploadedFile}
|
|
340
|
+
className="hidden"
|
|
341
|
+
/>
|
|
342
|
+
|
|
343
|
+
{/* Simulated input box upload area */}
|
|
344
|
+
<div
|
|
345
|
+
onClick={handleClick}
|
|
346
|
+
className={clsx(
|
|
347
|
+
"w-full pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-300 bg-transparent transition-colors duration-200 flex items-center justify-start min-h-[2.5rem]",
|
|
348
|
+
isUploading || uploadedFile
|
|
349
|
+
? "cursor-not-allowed opacity-75"
|
|
350
|
+
: "cursor-pointer hover:bg-white",
|
|
351
|
+
inputClassName
|
|
352
|
+
)}
|
|
353
|
+
style={{ paddingLeft }}
|
|
354
|
+
>
|
|
355
|
+
<span
|
|
356
|
+
className={clsx(
|
|
357
|
+
"pl-6 text-sm font-medium flex-1 mr-4",
|
|
358
|
+
uploadedFile ? "text-gray-900" : "text-gray-600"
|
|
359
|
+
)}
|
|
360
|
+
>
|
|
361
|
+
{getDisplayText()}
|
|
362
|
+
</span>
|
|
363
|
+
{isUploading ? (
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={handleCancelUpload}
|
|
367
|
+
className="ml-2 p-1 hover:bg-gray-100 rounded-full transition-colors flex-shrink-0 flex items-center gap-1"
|
|
368
|
+
title="Cancel upload"
|
|
369
|
+
>
|
|
370
|
+
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-primary-500" />
|
|
371
|
+
<svg
|
|
372
|
+
className="w-3 h-3 text-gray-500"
|
|
373
|
+
fill="none"
|
|
374
|
+
stroke="currentColor"
|
|
375
|
+
viewBox="0 0 24 24"
|
|
376
|
+
>
|
|
377
|
+
<path
|
|
378
|
+
strokeLinecap="round"
|
|
379
|
+
strokeLinejoin="round"
|
|
380
|
+
strokeWidth={2}
|
|
381
|
+
d="M6 18L18 6M6 6l12 12"
|
|
382
|
+
/>
|
|
383
|
+
</svg>
|
|
384
|
+
</button>
|
|
385
|
+
) : uploadedFile ? (
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
onClick={handleRemoveFile}
|
|
389
|
+
className="ml-2 p-1 hover:bg-red-100 rounded-full transition-colors flex-shrink-0"
|
|
390
|
+
title="Delete file"
|
|
391
|
+
>
|
|
392
|
+
<svg
|
|
393
|
+
className="w-4 h-4 text-red-500"
|
|
394
|
+
fill="none"
|
|
395
|
+
stroke="currentColor"
|
|
396
|
+
viewBox="0 0 24 24"
|
|
397
|
+
>
|
|
398
|
+
<path
|
|
399
|
+
strokeLinecap="round"
|
|
400
|
+
strokeLinejoin="round"
|
|
401
|
+
strokeWidth={2}
|
|
402
|
+
d="M6 18L18 6M6 6l12 12"
|
|
403
|
+
/>
|
|
404
|
+
</svg>
|
|
405
|
+
</button>
|
|
406
|
+
) : (
|
|
407
|
+
<svg
|
|
408
|
+
className="w-5 h-5 text-gray-400 flex-shrink-0"
|
|
409
|
+
fill="none"
|
|
410
|
+
stroke="currentColor"
|
|
411
|
+
viewBox="0 0 24 24"
|
|
412
|
+
>
|
|
413
|
+
<path
|
|
414
|
+
strokeLinecap="round"
|
|
415
|
+
strokeLinejoin="round"
|
|
416
|
+
strokeWidth={2}
|
|
417
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
418
|
+
/>
|
|
419
|
+
</svg>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
{/* Display error message */}
|
|
424
|
+
{(error || uploadError) && (
|
|
425
|
+
<p className="text-red-500 mt-1 text-sm">{error || uploadError}</p>
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
);
|