stunning screenshots in seconds https://moocup.jaydip.me
1import { useState } from "react"; 2import { Button } from "@/components/ui/button"; 3import { Card, CardContent, CardHeader } from "@/components/ui/card"; 4import { 5 Dialog, 6 DialogContent, 7 DialogTrigger, 8 DialogHeader, 9 DialogTitle, 10} from "@/components/ui/dialog"; 11import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 12import upiQR from "/assets/upiQR.png"; 13import { 14 Download, 15 ChevronDown, 16 Coffee, 17 ExternalLink, 18 Loader2, 19 Heart, 20 X, 21 CheckCircle, 22} from "lucide-react"; 23import { Github } from "@/components/icons/Github"; 24import { Bluesky } from "@/components/icons/Bluesky"; 25import { X as XIcon } from "@/components/icons/X"; 26 27import { toast } from "sonner"; 28import { useMockupStore } from "@/contexts/MockupContext"; 29import html2canvas from "html2canvas"; 30import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 31 32export function Navbar() { 33 const [showExportOptions, setShowExportOptions] = useState(false); 34 const [exportFormat, setExportFormat] = useState("PNG"); 35 const [quality, setQuality] = useState([2]); 36 const [isExporting, setIsExporting] = useState(false); 37 const [isDialogOpen, setIsDialogOpen] = useState(false); 38 39 const { uploadedImage, imageBorder, fixedMargin, margin } = useMockupStore(); 40 41 const isMobile = typeof window !== "undefined" ? window.innerWidth < 768 : false; 42 43 const getQualityLabel = (value) => { 44 switch (value) { 45 case 1: 46 return "Standard (1x)"; 47 case 2: 48 return "High (2x)"; 49 case 3: 50 return "Ultra (3x)"; 51 default: 52 return `${value}x`; 53 } 54 }; 55 56 const exportImage = async (format: string, qualityMultiplier: number) => { 57 if (!uploadedImage) return; 58 59 try { 60 const mockupElement = document.querySelector("[data-mockup-canvas]") as HTMLDivElement; 61 if (!mockupElement) throw new Error("Mockup canvas not found"); 62 63 const imgElement = mockupElement.querySelector("img") as HTMLImageElement; 64 if (!imgElement) throw new Error("Image element not found"); 65 66 // For fixed margin mode: capture the entire canvas (image + background margins) 67 const canvas = await html2canvas(mockupElement, { 68 scale: qualityMultiplier * 2, 69 useCORS: true, 70 allowTaint: true, 71 backgroundColor: null, 72 // Capture the entire mockup element without any cropping 73 }); 74 75 const mimeType = `image/${format.toLowerCase()}`; 76 const imageQuality = format === "JPEG" ? 1 : undefined; 77 78 canvas.toBlob( 79 (blob) => { 80 if (blob) { 81 const url = URL.createObjectURL(blob); 82 const a = document.createElement("a"); 83 a.href = url; 84 a.download = `mockup-${Date.now()}.${format.toLowerCase()}`; 85 document.body.appendChild(a); 86 a.click(); 87 document.body.removeChild(a); 88 URL.revokeObjectURL(url); 89 } else { 90 throw new Error("Failed to create blob"); 91 } 92 }, 93 mimeType, 94 imageQuality, 95 ); 96 } catch (error) { 97 console.error("Export error:", error); 98 throw error; 99 } 100 }; 101 102 const handleSingleExport = async () => { 103 if (!uploadedImage) { 104 toast.error("Please upload an image first"); 105 return; 106 } 107 108 setIsExporting(true); 109 try { 110 await exportImage(exportFormat, quality[0]); 111 toast.success(`Successfully exported as ${exportFormat}!`, { 112 icon: <CheckCircle className="w-4 h-4" />, 113 }); 114 } catch (error) { 115 toast.error("Failed to export image. Please try again."); 116 } finally { 117 setIsExporting(false); 118 } 119 }; 120 121 const handleAllFormatsExport = async () => { 122 if (!uploadedImage) { 123 toast.error("Please upload an image first"); 124 return; 125 } 126 127 setIsExporting(true); 128 const formats = ["PNG", "JPEG", "WebP"]; 129 const qualityMultiplier = quality[0]; 130 131 try { 132 await Promise.all(formats.map((format) => exportImage(format, qualityMultiplier))); 133 134 toast.success(`Successfully exported all formats (${formats.join(", ")})!`, { 135 icon: <CheckCircle className="w-4 h-4" />, 136 }); 137 } catch (error) { 138 console.error("Export all formats error:", error); 139 toast.error("Failed to export some formats. Please try again."); 140 } finally { 141 setIsExporting(false); 142 } 143 }; 144 145 const ExportOptionsContent = () => ( 146 <div 147 className={` 148 ${isMobile ? "flex flex-col gap-2" : "grid grid-cols-2 gap-6"} 149 `} 150 > 151 <div className={`${!isMobile ? "order-2" : "order-1"}`}> 152 <div> 153 <h3 className="text-xl font-semibold text-foreground mb-6 flex items-center gap-2"> 154 <Download className="w-5 h-5 text-primary" /> 155 Export Settings 156 </h3> 157 <div className="space-y-2"> 158 <div className="flex items-center justify-between"> 159 <label className="text-sm font-medium">Format</label> 160 </div> 161 <ToggleGroup 162 type="single" 163 value={exportFormat} 164 onValueChange={(value) => value && setExportFormat(value)} 165 className="flex gap-1 bg-sidebar/80 rounded-full ring-2 ring-secondary p-1" 166 > 167 {["PNG", "JPEG", "WebP"].map((format) => ( 168 <ToggleGroupItem 169 key={format} 170 value={format} 171 className={`flex-1 text-sm rounded-full cursor-pointer hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-primary/20 data-[state=on]:text-primary`} 172 > 173 {format} 174 </ToggleGroupItem> 175 ))} 176 </ToggleGroup> 177 </div> 178 179 <div className="space-y-2 mt-6"> 180 <div className="flex items-center justify-between"> 181 <label className="text-sm font-medium">Quality</label> 182 </div> 183 <ToggleGroup 184 type="single" 185 value={String(quality[0])} 186 onValueChange={(value) => value && setQuality([parseInt(value)])} 187 className="flex gap-1 bg-sidebar/80 rounded-full ring-2 ring-secondary p-1" 188 > 189 <ToggleGroupItem 190 value="1" 191 className="flex-1 text-sm rounded-full cursor-pointer hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-primary/20 data-[state=on]:text-primary" 192 > 193 Standard 194 </ToggleGroupItem> 195 <ToggleGroupItem 196 value="2" 197 className="flex-1 text-sm rounded-full cursor-pointer hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-primary/20 data-[state=on]:text-primary" 198 > 199 High 200 </ToggleGroupItem> 201 <ToggleGroupItem 202 value="3" 203 className="flex-1 text-sm rounded-full cursor-pointer hover:bg-secondary hover:text-secondary-foreground data-[state=on]:bg-primary/20 data-[state=on]:text-primary" 204 > 205 Ultra 206 </ToggleGroupItem> 207 </ToggleGroup> 208 </div> 209 </div> 210 <div className="space-y-3 grid grid-cols-2 gap-3 mt-12"> 211 <Button 212 onClick={handleSingleExport} 213 className="w-full h-12 font-medium shadow-md" 214 variant="secondary" 215 disabled={isExporting || !uploadedImage} 216 > 217 {isExporting ? ( 218 <> 219 <Loader2 className="w-5 h-5 mr-2 animate-spin" /> 220 Exporting... 221 </> 222 ) : ( 223 <> 224 <Download className="w-5 h-5 mr-2" /> 225 Export as {exportFormat} 226 </> 227 )} 228 </Button> 229 <Button 230 onClick={handleAllFormatsExport} 231 variant="outline" 232 className="w-full h-12 font-medium" 233 disabled={isExporting || !uploadedImage} 234 > 235 {isExporting ? ( 236 <> 237 <Loader2 className="w-5 h-5 mr-2 animate-spin" /> 238 Exporting All... 239 </> 240 ) : ( 241 <> 242 <Download className="w-5 h-5 mr-2" /> 243 Export All Formats 244 </> 245 )} 246 </Button> 247 </div> 248 249 <Button variant="link" className="w-full"> 250 <a 251 href="https://github.com/jellydeck/moocup" 252 target="_blank" 253 rel="noopener noreferrer" 254 className="inline-flex gap-2 w-full grid-cols-2" 255 > 256 <Github className="size-5" /> 257 Hey, You can also help us out at here 258 </a> 259 </Button> 260 </div> 261 262 <Card 263 className={`rounded-xl border-2 border-dashed border-primary/20 ${!isMobile ? "order-1" : "order-2"} group`} 264 > 265 <CardHeader className="pb-4"> 266 <div className="flex items-center gap-2"> 267 <Heart className="w-5 h-5 text-primary fill-primary/20 group-hover:fill-primary/50 transition-colors group-hover:animate-pulse group-hover:scale-110" /> 268 </div> 269 </CardHeader> 270 <CardContent className="space-y-4"> 271 <div> 272 <p className="text-sm text-foreground leading-relaxed"> 273 Hi, I'm 274 <a 275 href="https://jaydip.me" 276 target="_blank" 277 rel="noopener noreferrer" 278 className="inline-flex items-center gap-1 mx-1 text-primary underline hover:no-underline" 279 > 280 Jaydip 281 <ExternalLink className="w-3 h-3" /> 282 </a> 283 </p> 284 <p className="text-sm text-muted-foreground leading-relaxed"> 285 moocup is a simple offline tool. 286 </p> 287 <p className="text-sm text-muted-foreground leading-relaxed"> 288 focus on your craft, we'll take care of rest. 289 </p> 290 <p className="text-sm text-muted-foreground leading-relaxed"> 291 you can show your support by sponsoring my work! 292 </p> 293 </div> 294 <div className="space-y-3"> 295 <Button asChild className="w-full h-12 hover:primary/10 shadow-md"> 296 <a 297 href="https://ko-fi.com/jaydipsanghani" 298 target="_blank" 299 rel="noopener noreferrer" 300 className="flex items-center" 301 > 302 <Coffee className="w-5 h-5" /> 303 <span className="font-medium">Buy a coffee</span> 304 </a> 305 </Button> 306 <Dialog> 307 <DialogTrigger asChild> 308 <Button 309 variant="outline" 310 className="w-full h-12 border-primary/30 hover:bg-primary/5 hover:border-primary/50 inline-flex items-center" 311 > 312 <span className="font-medium">UPI (India)</span> 313 </Button> 314 </DialogTrigger> 315 <DialogContent className="max-w-xs"> 316 <DialogHeader> 317 <DialogTitle className="text-center">Thanks for contribution!</DialogTitle> 318 </DialogHeader> 319 <div className="flex flex-col items-center gap-4 py-4"> 320 <div className="w-48 h-48 bg-muted rounded-xl flex items-center justify-center border-2 border-dashed border-muted-foreground/20"> 321 <img 322 src={upiQR} 323 className="size-full" 324 fetchPriority="auto" 325 alt="QR code for making payment in Indian Rupees" 326 /> 327 </div> 328 <p className="text-sm text-muted-foreground text-center">Scan with any UPI app</p> 329 </div> 330 </DialogContent> 331 </Dialog> 332 </div> 333 <div className="space-y-2"> 334 <h4 className="text-sm font-medium text-muted-foreground"> 335 Say Hi! 336 <span className="ml-2 text-sm text-muted-foreground leading-relaxed"> 337 I'm always up for quick chat :) 338 </span> 339 </h4> 340 <div className="flex gap-2"> 341 <Button 342 variant="secondary" 343 size="sm" 344 asChild 345 className="flex-1 border-primary/30 hover:border-primary/50" 346 > 347 <a 348 href="https://bsky.app/profile/jellydeck.bsky.social" 349 target="_blank" 350 rel="noopener noreferrer" 351 className="flex items-center gap-2" 352 > 353 <Bluesky className="size-4" /> 354 <span>Bluesky</span> 355 </a> 356 </Button> 357 <Button 358 variant="secondary" 359 size="sm" 360 asChild 361 className="flex-1 border-primary/30 hover:border-primary/50" 362 > 363 <a 364 href="https://x.com/JellyDeck" 365 target="_blank" 366 rel="noopener noreferrer" 367 className="flex items-center gap-2" 368 > 369 <XIcon className="size-4" /> 370 <span>Twitter</span> 371 </a> 372 </Button> 373 </div> 374 </div> 375 </CardContent> 376 </Card> 377 </div> 378 ); 379 380 return ( 381 <div className="sticky top-0 z-50 bg-background/80 backdrop-blur-lg border-b"> 382 <div className="flex items-center justify-between px-4 h-16 lg:mx-40"> 383 <div className="flex items-center gap-4"> 384 <div className="flex items-center"> 385 <h1 className="text-xl font-bold text-primary">Moo</h1> 386 <a 387 href="https://ko-fi.com/jaydipsanghani" 388 target="_blank" 389 rel="noopener noreferrer" 390 className="flex items-center" 391 > 392 <Coffee className="w-6 h-6 text-primary hover:text-primary/80 hover:rotate-12 transition-transform cursor-pointer" /> 393 </a> 394 </div> 395 {!isMobile && ( 396 <div className="flex items-center gap-2 text-sm text-muted-foreground"> 397 <span>crafted by</span> 398 <Button variant="link" className="h-auto p-0 text-primary" asChild> 399 <a 400 href="https://jaydip.me" 401 target="_blank" 402 rel="noopener noreferrer" 403 className="flex items-center gap-1 underline hover:no-underline!" 404 > 405 Jaydip 406 </a> 407 </Button> 408 </div> 409 )} 410 </div> 411 <div className="flex items-center gap-2"> 412 <a href="https://github.com/jellydeck/moocup" target="_blank" rel="noopener noreferrer"> 413 <Button variant="outline" className="gap-2 mr-2"> 414 <Github className="size-5" /> 415 Send us a star! 416 </Button> 417 </a> 418 {isMobile ? ( 419 <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> 420 <DialogTrigger asChild> 421 <Button size="icon" disabled={!uploadedImage} className="h-10 w-10"> 422 <Download className="w-5 h-5" /> 423 </Button> 424 </DialogTrigger> 425 <DialogContent className="w-[95vw] max-w-md h-[90vh] max-h-[90vh] flex flex-col p-0 [&>button:first-of-type]:hidden"> 426 <div className="flex items-center justify-between p-4 border-b shrink-0"> 427 <DialogTitle className="text-lg font-semibold">Export & Support</DialogTitle> 428 <Button 429 variant="ghost" 430 size="icon" 431 onClick={() => setIsDialogOpen(false)} 432 className="h-8 w-8" 433 > 434 <X className="w-4 h-4" /> 435 </Button> 436 </div> 437 <div className="flex-1 overflow-y-auto"> 438 <div className="px-4 pb-4"> 439 <ExportOptionsContent /> 440 </div> 441 </div> 442 </DialogContent> 443 </Dialog> 444 ) : ( 445 <Popover> 446 <PopoverTrigger asChild> 447 <Button variant="outline" disabled={!uploadedImage} className="gap-2 group"> 448 <Download className="w-4 h-4" /> 449 Export 450 <ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" /> 451 </Button> 452 </PopoverTrigger> 453 454 <PopoverContent 455 align="end" 456 side="bottom" 457 className="w-[900px] p-0.5 border rounded-2xl mt-1.5" 458 > 459 <Card className="shadow-none border-0"> 460 <CardContent className="p-6"> 461 <ExportOptionsContent /> 462 </CardContent> 463 </Card> 464 </PopoverContent> 465 </Popover> 466 )} 467 </div> 468 </div> 469 </div> 470 ); 471}