| 1 |
/** |
| 2 |
* Media Upload Component |
| 3 |
* Handles image and video uploads for itinerary entries |
| 4 |
*/ |
| 5 |
|
| 6 |
import React from "react"; |
| 7 |
import { Upload, X, Video, Plus } from "lucide-react"; |
| 8 |
import { Button } from "./button"; |
| 9 |
import { Card, CardContent } from "./card"; |
| 10 |
import { __ } from "../../lib/i18n"; |
| 11 |
import { prepareWordPressMediaFrameOpen } from "../../lib/wp-media-open"; |
| 12 |
import { MediaItem } from "../trip-form/types"; |
| 13 |
|
| 14 |
interface MediaUploadProps { |
| 15 |
items: MediaItem[]; |
| 16 |
onChange: (items: MediaItem[]) => void; |
| 17 |
maxItems?: number; |
| 18 |
acceptTypes?: "images" | "videos" | "both"; |
| 19 |
title?: string; |
| 20 |
description?: string; |
| 21 |
className?: string; |
| 22 |
} |
| 23 |
|
| 24 |
export const MediaUpload: React.FC<MediaUploadProps> = ({ |
| 25 |
items = [], |
| 26 |
onChange, |
| 27 |
maxItems = 10, |
| 28 |
acceptTypes = "both", |
| 29 |
title = __("Media Gallery", "yatra"), |
| 30 |
description = __("Add images and videos to showcase this activity", "yatra"), |
| 31 |
className = "", |
| 32 |
}) => { |
| 33 |
const getMediaType = (acceptTypes: string): string => { |
| 34 |
switch (acceptTypes) { |
| 35 |
case "images": |
| 36 |
return "image"; |
| 37 |
case "videos": |
| 38 |
return "video"; |
| 39 |
default: |
| 40 |
return "image,video"; |
| 41 |
} |
| 42 |
}; |
| 43 |
|
| 44 |
const getMediaTitle = (acceptTypes: string): string => { |
| 45 |
switch (acceptTypes) { |
| 46 |
case "images": |
| 47 |
return __("Photo Gallery", "yatra"); |
| 48 |
case "videos": |
| 49 |
return __("Video Gallery", "yatra"); |
| 50 |
default: |
| 51 |
return title; |
| 52 |
} |
| 53 |
}; |
| 54 |
|
| 55 |
const getMediaDescription = (acceptTypes: string): string => { |
| 56 |
switch (acceptTypes) { |
| 57 |
case "images": |
| 58 |
return __("Upload images to showcase this activity", "yatra"); |
| 59 |
case "videos": |
| 60 |
return __("Add videos to showcase this activity", "yatra"); |
| 61 |
default: |
| 62 |
return description; |
| 63 |
} |
| 64 |
}; |
| 65 |
|
| 66 |
const handleMediaAdd = () => { |
| 67 |
if (items.length >= maxItems) { |
| 68 |
alert(__(`Maximum ${maxItems} media items allowed`, "yatra")); |
| 69 |
return; |
| 70 |
} |
| 71 |
|
| 72 |
// Use WordPress media library |
| 73 |
if (window.wp && window.wp.media) { |
| 74 |
const mediaUploader = window.wp.media({ |
| 75 |
title: __("Select Media", "yatra"), |
| 76 |
button: { text: __("Add Media", "yatra") }, |
| 77 |
multiple: true, // Allow multiple selection |
| 78 |
library: { type: getMediaType(acceptTypes) }, |
| 79 |
}); |
| 80 |
|
| 81 |
mediaUploader.on("select", () => { |
| 82 |
const selection = mediaUploader.state().get("selection"); |
| 83 |
const newItems: MediaItem[] = []; |
| 84 |
|
| 85 |
selection.each((attachment: any) => { |
| 86 |
const mediaType = attachment.get("type"); |
| 87 |
|
| 88 |
const mediaItem: MediaItem = { |
| 89 |
id: attachment.get("id").toString(), |
| 90 |
attachment_id: attachment.get("id"), |
| 91 |
url: attachment.get("url"), |
| 92 |
type: mediaType as "image" | "video", |
| 93 |
alt_text: attachment.get("alt") || "", |
| 94 |
caption: attachment.get("caption") || "", |
| 95 |
}; |
| 96 |
|
| 97 |
if (mediaType === "image") { |
| 98 |
const sizes = attachment.get("sizes"); |
| 99 |
mediaItem.thumbnail_url = |
| 100 |
sizes?.medium?.url || |
| 101 |
sizes?.thumbnail?.url || |
| 102 |
attachment.get("url"); |
| 103 |
} |
| 104 |
|
| 105 |
newItems.push(mediaItem); |
| 106 |
}); |
| 107 |
|
| 108 |
if (newItems.length > 0) { |
| 109 |
onChange([...items, ...newItems]); |
| 110 |
} |
| 111 |
}); |
| 112 |
|
| 113 |
prepareWordPressMediaFrameOpen(); |
| 114 |
mediaUploader.open(); |
| 115 |
} else { |
| 116 |
// Fallback for non-WordPress environments |
| 117 |
const input = document.createElement("input"); |
| 118 |
input.type = "file"; |
| 119 |
input.multiple = true; |
| 120 |
input.accept = |
| 121 |
acceptTypes === "videos" |
| 122 |
? "video/*" |
| 123 |
: acceptTypes === "images" |
| 124 |
? "image/*" |
| 125 |
: "image/*,video/*"; |
| 126 |
|
| 127 |
input.onchange = (e) => { |
| 128 |
const files = Array.from((e.target as HTMLInputElement).files || []); |
| 129 |
const newItems: MediaItem[] = files |
| 130 |
.slice(0, maxItems - items.length) |
| 131 |
.map((file, index) => ({ |
| 132 |
id: `temp_${Date.now()}_${index}`, |
| 133 |
attachment_id: 0, // Temporary files don't have attachment IDs |
| 134 |
url: URL.createObjectURL(file), |
| 135 |
type: file.type.startsWith("video/") ? "video" : "image", |
| 136 |
alt_text: file.name, |
| 137 |
caption: file.name, |
| 138 |
})); |
| 139 |
|
| 140 |
onChange([...items, ...newItems]); |
| 141 |
}; |
| 142 |
|
| 143 |
input.click(); |
| 144 |
} |
| 145 |
}; |
| 146 |
|
| 147 |
const handleMediaRemove = (index: number) => { |
| 148 |
onChange(items.filter((_, i) => i !== index)); |
| 149 |
}; |
| 150 |
|
| 151 |
const isImage = (item: MediaItem) => item.type === "image"; |
| 152 |
|
| 153 |
return ( |
| 154 |
<Card className={className}> |
| 155 |
<CardContent className="p-6"> |
| 156 |
<div className="space-y-4"> |
| 157 |
<div className="flex items-center justify-between"> |
| 158 |
<div> |
| 159 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> |
| 160 |
{getMediaTitle(acceptTypes)} |
| 161 |
</h3> |
| 162 |
<p className="text-sm text-gray-600 dark:text-gray-400"> |
| 163 |
{getMediaDescription(acceptTypes)} |
| 164 |
</p> |
| 165 |
</div> |
| 166 |
<span className="text-sm text-gray-500 dark:text-gray-400"> |
| 167 |
{items.length}/{maxItems} |
| 168 |
</span> |
| 169 |
</div> |
| 170 |
|
| 171 |
{items.length > 0 && ( |
| 172 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> |
| 173 |
{items.map((item, index) => ( |
| 174 |
<div key={item.id} className="relative group"> |
| 175 |
<div className="aspect-video bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center overflow-hidden"> |
| 176 |
{isImage(item) ? ( |
| 177 |
<img |
| 178 |
src={item.thumbnail_url || item.url} |
| 179 |
alt={item.alt_text || `Media ${index + 1}`} |
| 180 |
className="w-full h-full object-cover" |
| 181 |
onError={(e) => { |
| 182 |
console.error("Image load error:", e, "Item:", item); // Debug log |
| 183 |
// Fallback to placeholder or broken image icon |
| 184 |
(e.target as HTMLImageElement).style.display = "none"; |
| 185 |
const parent = (e.target as HTMLImageElement) |
| 186 |
.parentElement; |
| 187 |
if (parent) { |
| 188 |
parent.innerHTML = |
| 189 |
'<div class="flex items-center justify-center w-full h-full"><div class="text-gray-400 text-center"><div class="text-4xl mb-2">🖼️</div><div class="text-sm">Image not available</div></div></div>'; |
| 190 |
} |
| 191 |
}} |
| 192 |
onLoad={() => { |
| 193 |
// Image loaded successfully |
| 194 |
}} |
| 195 |
/> |
| 196 |
) : ( |
| 197 |
<div className="relative w-full h-full"> |
| 198 |
<video |
| 199 |
src={item.url} |
| 200 |
className="w-full h-full object-cover" |
| 201 |
muted |
| 202 |
playsInline |
| 203 |
/> |
| 204 |
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30"> |
| 205 |
<Video className="w-12 h-12 text-white" /> |
| 206 |
</div> |
| 207 |
</div> |
| 208 |
)} |
| 209 |
</div> |
| 210 |
|
| 211 |
{/* Delete Button */} |
| 212 |
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"> |
| 213 |
<Button |
| 214 |
type="button" |
| 215 |
size="sm" |
| 216 |
variant="destructive" |
| 217 |
onClick={() => handleMediaRemove(index)} |
| 218 |
className="h-8 w-8 p-0 bg-red-500 hover:bg-red-600 text-white" |
| 219 |
> |
| 220 |
<X className="w-4 h-4" /> |
| 221 |
</Button> |
| 222 |
</div> |
| 223 |
|
| 224 |
{/* Caption */} |
| 225 |
{item.caption && ( |
| 226 |
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2"> |
| 227 |
<p className="text-xs text-white truncate"> |
| 228 |
{item.caption} |
| 229 |
</p> |
| 230 |
</div> |
| 231 |
)} |
| 232 |
</div> |
| 233 |
))} |
| 234 |
</div> |
| 235 |
)} |
| 236 |
|
| 237 |
{/* Add Media Button */} |
| 238 |
{items.length < maxItems && ( |
| 239 |
<div className="flex justify-center"> |
| 240 |
<Button |
| 241 |
type="button" |
| 242 |
onClick={handleMediaAdd} |
| 243 |
variant="outline" |
| 244 |
className="w-full md:w-auto" |
| 245 |
> |
| 246 |
<Plus className="w-4 h-4 mr-2" /> |
| 247 |
{acceptTypes === "videos" |
| 248 |
? __("Add Video", "yatra") |
| 249 |
: acceptTypes === "images" |
| 250 |
? __("Add Image", "yatra") |
| 251 |
: __("Add Media", "yatra")} |
| 252 |
</Button> |
| 253 |
</div> |
| 254 |
)} |
| 255 |
|
| 256 |
{items.length === 0 && ( |
| 257 |
<div className="text-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg"> |
| 258 |
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" /> |
| 259 |
<p className="text-gray-600 dark:text-gray-400"> |
| 260 |
{__( |
| 261 |
"No media items yet. Click 'Add Media' to get started.", |
| 262 |
"yatra", |
| 263 |
)} |
| 264 |
</p> |
| 265 |
</div> |
| 266 |
)} |
| 267 |
</div> |
| 268 |
</CardContent> |
| 269 |
</Card> |
| 270 |
); |
| 271 |
}; |
| 272 |
|