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}