| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | AlertTriangle, |
| 5 | ExternalLink, |
| 6 | Loader2, |
| 7 | Trash2, |
| 8 | Upload, |
| 9 | } from "lucide-react"; |
| 10 | import Image from "next/image"; |
| 11 | import { type TElement } from "platejs"; |
| 12 | import { useEffect, useRef, useState } from "react"; |
| 13 | |
| 14 | import { useUploadFile } from "@/components/plate/hooks/use-upload-file"; |
| 15 | import { |
| 16 | detectEmbedType, |
| 17 | getAllEmbedTypes, |
| 18 | isValidEmbedUrl, |
| 19 | } from "@/components/plate/ui/media-embeds"; |
| 20 | import { Alert, AlertDescription } from "@/components/ui/alert"; |
| 21 | import { Button } from "@/components/ui/button"; |
| 22 | import { Input } from "@/components/ui/input"; |
| 23 | import { Label } from "@/components/ui/label"; |
| 24 | import { |
| 25 | Select, |
| 26 | SelectContent, |
| 27 | SelectItem, |
| 28 | SelectTrigger, |
| 29 | SelectValue, |
| 30 | } from "@/components/ui/select"; |
| 31 | import { type RootImage as RootImageType } from "../../../utils/parser"; |
| 32 | import { type ImageCropSettings } from "../../../utils/types"; |
| 33 | import { EmbedRenderer } from "../embeds/EmbedRenderer"; |
| 34 | import { ActionButtons } from "./ActionButtons"; |
| 35 | import { ImagePreview } from "./ImagePreview"; |
| 36 | import { type ImageDimensions } from "./useImageDimensions"; |
| 37 | |
| 38 | interface EmbedControlsProps { |
| 39 | embedType?: string; |
| 40 | embedUrl?: string; |
| 41 | onEmbedChange: (embedType: string, url: string) => void; |
| 42 | onClearEmbed: () => void; |
| 43 | imageDimensions?: ImageDimensions; |
| 44 | // Additional props for full image functionality |
| 45 | element?: TElement & RootImageType; |
| 46 | slideId?: string; |
| 47 | isRootImage?: boolean; |
| 48 | onOpenCrop?: () => void; |
| 49 | cropSettings?: ImageCropSettings; |
| 50 | onCropSettingsChange?: (settings: ImageCropSettings) => void; |
| 51 | } |
| 52 | |
| 53 | export function EmbedControls({ |
| 54 | embedType, |
| 55 | embedUrl, |
| 56 | onEmbedChange, |
| 57 | onClearEmbed, |
| 58 | imageDimensions, |
| 59 | element, |
| 60 | slideId, |
| 61 | isRootImage = true, |
| 62 | onOpenCrop, |
| 63 | cropSettings, |
| 64 | onCropSettingsChange, |
| 65 | }: EmbedControlsProps) { |
| 66 | const [selectedType, setSelectedType] = useState(embedType || "image"); |
| 67 | const [url, setUrl] = useState(embedUrl || ""); |
| 68 | const [error, setError] = useState<string | null>(null); |
| 69 | const [isValid, setIsValid] = useState(false); |
| 70 | const fileInputRef = useRef<HTMLInputElement>(null); |
| 71 | |
| 72 | const embedTypes = getAllEmbedTypes(); |
| 73 | |
| 74 | // Upload hook for image upload |
| 75 | const { uploadFile, isUploading, progress } = useUploadFile({ |
| 76 | onUploadComplete: (file) => { |
| 77 | const uploadedUrl = file.ufsUrl ?? file.ufsUrl; |
| 78 | setUrl(uploadedUrl); |
| 79 | setSelectedType("image"); |
| 80 | setIsValid(true); |
| 81 | setError(null); |
| 82 | // Automatically apply the embed with the uploaded image URL |
| 83 | onEmbedChange("image", uploadedUrl); |
| 84 | }, |
| 85 | onUploadError: (error) => { |
| 86 | setError("Failed to upload image"); |
| 87 | console.error(error); |
| 88 | }, |
| 89 | }); |
| 90 | |
| 91 | const handleUploadClick = () => { |
| 92 | fileInputRef.current?.click(); |
| 93 | }; |
| 94 | |
| 95 | const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 96 | const file = e.target.files?.[0]; |
| 97 | if (file) void uploadFile(file); |
| 98 | // Reset the input so the same file can be re-selected |
| 99 | e.target.value = ""; |
| 100 | }; |
| 101 | |
| 102 | useEffect(() => { |
| 103 | if (url && selectedType) { |
| 104 | const valid = isValidEmbedUrl(url, selectedType); |
| 105 | setIsValid(valid); |
| 106 | setError(valid ? null : "Invalid URL for selected embed type"); |
| 107 | } else { |
| 108 | setIsValid(false); |
| 109 | setError(null); |
| 110 | } |
| 111 | }, [url, selectedType]); |
| 112 | |
| 113 | const handleUrlChange = (newUrl: string) => { |
| 114 | setUrl(newUrl); |
| 115 | |
| 116 | // Auto-detect embed type if not selected |
| 117 | if (!selectedType && newUrl) { |
| 118 | const detectedType = detectEmbedType(newUrl); |
| 119 | if (detectedType) { |
| 120 | setSelectedType(detectedType); |
| 121 | } |
| 122 | } |
| 123 | }; |
| 124 | |
| 125 | const handleApply = () => { |
| 126 | if (selectedType && url && isValid) { |
| 127 | onEmbedChange(selectedType, url); |
| 128 | } |
| 129 | }; |
| 130 | |
| 131 | const handleClear = () => { |
| 132 | setSelectedType(""); |
| 133 | setUrl(""); |
| 134 | setError(null); |
| 135 | setIsValid(false); |
| 136 | onClearEmbed(); |
| 137 | }; |
| 138 | |
| 139 | // Calculate thumbnail scale to fit in 90x90 box |
| 140 | const thumbnailDimensions = imageDimensions |
| 141 | ? { |
| 142 | ...imageDimensions, |
| 143 | scale: Math.min( |
| 144 | 360 / imageDimensions.width, |
| 145 | 360 / imageDimensions.height, |
| 146 | ), |
| 147 | } |
| 148 | : { width: 360, height: 360, scale: 1 }; |
| 149 | |
| 150 | // Create element for ImagePreview when type is "image" |
| 151 | const imageElement = element ?? { |
| 152 | type: "rootImage" as const, |
| 153 | children: [] as never[], |
| 154 | url: url, |
| 155 | query: "", |
| 156 | }; |
| 157 | |
| 158 | // Local crop settings for image preview |
| 159 | const localCropSettings = cropSettings ?? { |
| 160 | objectFit: "cover" as const, |
| 161 | objectPosition: { x: 50, y: 50 }, |
| 162 | zoom: 1, |
| 163 | }; |
| 164 | |
| 165 | const isImageType = selectedType === "image"; |
| 166 | |
| 167 | return ( |
| 168 | <div className="flex w-90 flex-col gap-y-6 overflow-clip"> |
| 169 | {/* Mini Preview */} |
| 170 | <div className="flex flex-col gap-2"> |
| 171 | <div className="flex justify-center"> |
| 172 | <div className="relative flex size-90 items-center justify-center overflow-hidden rounded-md border bg-muted shadow"> |
| 173 | {isImageType ? ( |
| 174 | imageDimensions ? ( |
| 175 | <ImagePreview |
| 176 | element={ |
| 177 | { |
| 178 | ...imageElement, |
| 179 | url: url || imageElement.url, |
| 180 | } as TElement & RootImageType |
| 181 | } |
| 182 | currentMode="embed" |
| 183 | localCropSettings={localCropSettings} |
| 184 | imageDimensions={thumbnailDimensions} |
| 185 | onCropSettingsChange={onCropSettingsChange ?? (() => {})} |
| 186 | hideControls={true} |
| 187 | /> |
| 188 | ) : url ? ( |
| 189 | <Image |
| 190 | unoptimized |
| 191 | width={400} |
| 192 | height={300} |
| 193 | src={url} |
| 194 | alt="Preview" |
| 195 | className="size-full object-cover" |
| 196 | /> |
| 197 | ) : ( |
| 198 | <div className="flex items-center justify-center text-sm text-muted-foreground"> |
| 199 | Enter an image URL |
| 200 | </div> |
| 201 | ) |
| 202 | ) : ( |
| 203 | <div |
| 204 | style={{ |
| 205 | width: thumbnailDimensions.width * thumbnailDimensions.scale, |
| 206 | height: |
| 207 | thumbnailDimensions.height * thumbnailDimensions.scale, |
| 208 | }} |
| 209 | > |
| 210 | <EmbedRenderer |
| 211 | embedType={selectedType} |
| 212 | url={url} |
| 213 | className="pointer-events-none size-full" |
| 214 | /> |
| 215 | </div> |
| 216 | )} |
| 217 | </div> |
| 218 | </div> |
| 219 | |
| 220 | {/* Action Buttons - Only show for image type when we have a URL */} |
| 221 | {isImageType && (url || embedUrl) && element && ( |
| 222 | <div className="flex justify-center"> |
| 223 | <ActionButtons |
| 224 | element={element} |
| 225 | slideId={slideId} |
| 226 | isRootImage={isRootImage} |
| 227 | imageUrl={url || embedUrl} |
| 228 | onOpenCrop={onOpenCrop} |
| 229 | cropSettings={localCropSettings} |
| 230 | onCropSettingsChange={onCropSettingsChange} |
| 231 | showInOverlay={true} |
| 232 | /> |
| 233 | </div> |
| 234 | )} |
| 235 | </div> |
| 236 | |
| 237 | <div className="max-w-full space-y-3"> |
| 238 | <Label className="text-sm font-medium">Embed Type</Label> |
| 239 | <Select value={selectedType} onValueChange={setSelectedType}> |
| 240 | <SelectTrigger> |
| 241 | <SelectValue placeholder="Select embed type" /> |
| 242 | </SelectTrigger> |
| 243 | <SelectContent> |
| 244 | {embedTypes.map(({ type, config }) => ( |
| 245 | <SelectItem key={type} value={type}> |
| 246 | {config.name} |
| 247 | </SelectItem> |
| 248 | ))} |
| 249 | </SelectContent> |
| 250 | </Select> |
| 251 | </div> |
| 252 | |
| 253 | <div className="max-w-full space-y-3"> |
| 254 | <Label className="text-sm font-medium">URL</Label> |
| 255 | <div className="flex gap-2"> |
| 256 | <Input |
| 257 | placeholder={ |
| 258 | isImageType |
| 259 | ? "Paste your image URL here..." |
| 260 | : "Paste your embed URL here..." |
| 261 | } |
| 262 | value={url} |
| 263 | onChange={(e) => handleUrlChange(e.target.value)} |
| 264 | className="flex-1 font-mono text-sm" |
| 265 | disabled={isUploading} |
| 266 | /> |
| 267 | {isImageType && ( |
| 268 | <Button |
| 269 | type="button" |
| 270 | variant="outline" |
| 271 | size="icon" |
| 272 | onClick={handleUploadClick} |
| 273 | disabled={isUploading} |
| 274 | title="Upload image" |
| 275 | className="shrink-0" |
| 276 | > |
| 277 | {isUploading ? ( |
| 278 | <Loader2 className="size-4 animate-spin" /> |
| 279 | ) : ( |
| 280 | <Upload className="size-4" /> |
| 281 | )} |
| 282 | </Button> |
| 283 | )} |
| 284 | {/* Hidden file input */} |
| 285 | <input |
| 286 | aria-label="embed controls control" |
| 287 | ref={fileInputRef} |
| 288 | type="file" |
| 289 | accept="image/*" |
| 290 | onChange={handleFileChange} |
| 291 | className="hidden" |
| 292 | /> |
| 293 | </div> |
| 294 | {isUploading && ( |
| 295 | <div className="text-xs text-muted-foreground"> |
| 296 | Uploading… {Math.round(progress)}% |
| 297 | </div> |
| 298 | )} |
| 299 | {error && ( |
| 300 | <Alert variant="destructive"> |
| 301 | <AlertTriangle className="size-4" /> |
| 302 | <AlertDescription>{error}</AlertDescription> |
| 303 | </Alert> |
| 304 | )} |
| 305 | </div> |
| 306 | |
| 307 | <div className="space-y-3 pt-2"> |
| 308 | <Button |
| 309 | onClick={handleApply} |
| 310 | disabled={!selectedType || !url || !isValid} |
| 311 | className="w-full" |
| 312 | > |
| 313 | {isImageType ? "Apply Image URL" : "Apply Embed"} |
| 314 | </Button> |
| 315 | |
| 316 | {embedType && ( |
| 317 | <Button |
| 318 | variant="outline" |
| 319 | onClick={handleClear} |
| 320 | className="w-full text-destructive hover:text-destructive" |
| 321 | > |
| 322 | <Trash2 className="mr-2 size-4" /> |
| 323 | Remove Embed |
| 324 | </Button> |
| 325 | )} |
| 326 | </div> |
| 327 | |
| 328 | {url && ( |
| 329 | <div className="flex items-center gap-2 overflow-hidden rounded-md bg-muted/50 p-3 text-xs text-muted-foreground"> |
| 330 | <ExternalLink className="size-3 shrink-0" /> |
| 331 | <a |
| 332 | href={url} |
| 333 | target="_blank" |
| 334 | rel="noopener noreferrer" |
| 335 | className="line-clamp-1 max-w-full flex-1 text-ellipsis hover:underline" |
| 336 | > |
| 337 | {url} |
| 338 | </a> |
| 339 | </div> |
| 340 | )} |
| 341 | </div> |
| 342 | ); |
| 343 | } |
| 344 |