返回 presentation-ai
media-embed-placeholder.tsx
根目录 / src / components / plate / ui / media-embed-placeholder.tsx
1 "use client";
2
3 import { AlertTriangle, ExternalLink, Loader2, Upload } from "lucide-react";
4 import { useRef, useState } from "react";
5
6 import { useUploadFile } from "@/components/plate/hooks/use-upload-file";
7 import { Button } from "@/components/ui/button";
8 import { Input } from "@/components/ui/input";
9 import { Label } from "@/components/ui/label";
10 import { cn } from "@/lib/utils";
11 import { embedTypeConfig, isValidEmbedUrl } from "./media-embeds";
12
13 interface MediaEmbedPlaceholderProps {
14 embedType: string;
15 onUrlSubmit: (url: string) => void;
16 className?: string;
17 }
18
19 export function MediaEmbedPlaceholder({
20 embedType,
21 onUrlSubmit,
22 className,
23 }: MediaEmbedPlaceholderProps) {
24 const [url, setUrl] = useState("");
25 const [error, setError] = useState<string | null>(null);
26 const [isValid, setIsValid] = useState(false);
27 const fileInputRef = useRef<HTMLInputElement>(null);
28
29 const config =
30 embedTypeConfig[embedType as keyof typeof embedTypeConfig] ||
31 embedTypeConfig.youtube;
32
33 const isImageType = embedType === "image";
34
35 // Upload hook for image upload
36 const { uploadFile, isUploading, progress } = useUploadFile({
37 onUploadComplete: (file) => {
38 const uploadedUrl = file.ufsUrl ?? file.url;
39 if (uploadedUrl) {
40 setUrl(uploadedUrl);
41 setIsValid(true);
42 setError(null);
43 // Automatically submit the uploaded image URL
44 onUrlSubmit(uploadedUrl);
45 }
46 },
47 onUploadError: (uploadError) => {
48 setError("Failed to upload image");
49 console.error(uploadError);
50 },
51 });
52
53 const handleUploadClick = () => {
54 fileInputRef.current?.click();
55 };
56
57 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
58 const file = e.target.files?.[0];
59 if (file) void uploadFile(file);
60 // Reset the input so the same file can be re-selected
61 e.target.value = "";
62 };
63
64 if (!config) {
65 return (
66 <div className="p-6 text-center text-muted-foreground">
67 <AlertTriangle className="mx-auto mb-2 size-8" />
68 <p>Invalid embed type: {embedType}</p>
69 </div>
70 );
71 }
72
73 // TypeScript assertion that config is defined after the null check
74 const safeConfig = config;
75
76 const handleUrlChange = (newUrl: string) => {
77 setUrl(newUrl);
78 setError(null);
79
80 if (newUrl.trim()) {
81 const valid = isValidEmbedUrl(newUrl, embedType);
82 setIsValid(valid);
83 if (!valid) {
84 setError(`Invalid ${safeConfig.name} URL format`);
85 }
86 } else {
87 setIsValid(false);
88 }
89 };
90
91 const handleSubmit = () => {
92 if (url.trim() && isValid) {
93 onUrlSubmit(url.trim());
94 }
95 };
96
97 const handleKeyDown = (e: React.KeyboardEvent) => {
98 if (e.key === "Enter" && url.trim() && isValid) {
99 handleSubmit();
100 }
101 };
102
103 return (
104 <div
105 className={cn(
106 "relative w-full overflow-hidden rounded-lg border-2 border-dashed",
107 className,
108 )}
109 >
110 {/* Background with embed type color */}
111 <div className="absolute inset-0 opacity-5" />
112
113 {/* Content */}
114 <div className="relative flex min-h-50 flex-col items-center justify-center gap-y-4 p-6">
115 {/* Icon and title */}
116 <div className="flex flex-col items-center gap-y-2">
117 <div className="flex size-16 items-center justify-center rounded-2xl text-3xl shadow">
118 {safeConfig.icon}
119 </div>
120 <div className="text-center">
121 <h3 className="text-lg font-semibold">{safeConfig.name}</h3>
122 <p className="text-sm text-muted-foreground">
123 {safeConfig.description}
124 </p>
125 </div>
126 </div>
127
128 {/* URL Input */}
129 <div className="w-full max-w-sm space-y-2">
130 <Label htmlFor="embed-url" className="text-sm font-medium">
131 URL
132 </Label>
133 <div className="flex gap-2">
134 <Input
135 id="embed-url"
136 type="url"
137 placeholder={safeConfig.placeholder}
138 value={url}
139 onChange={(e) => handleUrlChange(e.target.value)}
140 onKeyDown={handleKeyDown}
141 className="h-10 flex-1"
142 disabled={isUploading}
143 />
144 {isImageType && (
145 <Button
146 type="button"
147 variant="outline"
148 size="icon"
149 onClick={handleUploadClick}
150 disabled={isUploading}
151 title="Upload image"
152 className="size-10 shrink-0"
153 >
154 {isUploading ? (
155 <Loader2 className="size-4 animate-spin" />
156 ) : (
157 <Upload className="size-4" />
158 )}
159 </Button>
160 )}
161 {/* Hidden file input */}
162 <input
163 aria-label="media embed placeholder control"
164 ref={fileInputRef}
165 type="file"
166 accept="image/*"
167 onChange={handleFileChange}
168 className="hidden"
169 />
170 </div>
171 {isUploading && (
172 <div className="text-xs text-muted-foreground">
173 Uploading… {Math.round(progress)}%
174 </div>
175 )}
176 {error && (
177 <div className="flex items-center gap-2 text-sm text-destructive">
178 <AlertTriangle className="size-4" />
179 {error}
180 </div>
181 )}
182 </div>
183
184 {/* Submit Button */}
185 <Button
186 onClick={handleSubmit}
187 disabled={!url.trim() || !isValid || isUploading}
188 className="h-10 px-6 font-medium"
189 >
190 Add {safeConfig.name}
191 </Button>
192
193 {/* Example URL */}
194 {url && (
195 <div className="flex items-center gap-2 text-xs text-muted-foreground">
196 <ExternalLink className="size-3" />
197 <span className="max-w-xs truncate">{url}</span>
198 </div>
199 )}
200 </div>
201 </div>
202 );
203 }
204
204 lines Plain Text