返回 presentation-ai
ExportButton.tsx
根目录 / src / components / presentation / buttons / ExportButton.tsx
1 // components/export-ppt-button.tsx
2 "use client";
3
4 import { Download, Loader2 } from "lucide-react";
5 import { useState } from "react";
6
7 import { Button } from "@/components/ui/button";
8 import {
9 Dialog,
10 DialogContent,
11 DialogDescription,
12 DialogFooter,
13 DialogHeader,
14 DialogTitle,
15 DialogTrigger,
16 } from "@/components/ui/dialog";
17 import { Label } from "@/components/ui/label";
18 import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
19 import { useToast } from "@/components/ui/use-toast";
20 import { raiseError } from "@/lib/raise-error";
21 import { usePresentationState } from "@/states/presentation-state";
22 import { scanAllSlides } from "../export/domSlideScanner";
23 import { exportPresentationToPptx } from "../export/domToPptxConverter";
24
25 const EXPORT_SUCCESS_TOAST_DURATION_MS = 10000;
26 const FALLBACK_DOWNLOAD_LINK_TTL_MS = 60000;
27
28 function startDownload(blob: Blob, fileName: string) {
29 const downloadUrl = URL.createObjectURL(blob);
30 const link = document.createElement("a");
31 link.href = downloadUrl;
32 link.download = fileName;
33 document.body.appendChild(link);
34 link.click();
35 document.body.removeChild(link);
36
37 window.setTimeout(() => {
38 URL.revokeObjectURL(downloadUrl);
39 }, FALLBACK_DOWNLOAD_LINK_TTL_MS);
40
41 return downloadUrl;
42 }
43
44 export function ExportButton() {
45 const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
46 const [isExporting, setIsExporting] = useState(false);
47 const { toast } = useToast();
48
49 const handleExport = async () => {
50 try {
51 setIsExporting(true);
52
53 // Get all slide IDs
54 const { slides, currentPresentationTitle } =
55 usePresentationState.getState();
56 const slideIds = slides.map((slide) => slide.id);
57
58 if (slideIds.length === 0) {
59 raiseError(new Error("No slides to export"));
60 }
61
62 // Show single toast with loader
63 const { update, dismiss } = toast({
64 title: "Exporting Presentation",
65 description: (
66 <div className="flex items-center gap-2">
67 <Loader2 className="size-4 animate-spin" />
68 <span>{"Scanning slides..."}</span>
69 </div>
70 ),
71 duration: Infinity, // Keep open until we dismiss
72 });
73
74 // Scan all slides in the DOM (now parallel)
75 const scanResults = await scanAllSlides(slides);
76
77 if (scanResults.length === 0) {
78 raiseError(
79 new Error(
80 "Failed to scan slides. Please ensure all slides are visible on the page.",
81 ),
82 );
83 }
84
85 update({
86 description: (
87 <div className="flex items-center gap-2">
88 <Loader2 className="size-4 animate-spin" />
89 <span>{"Generating PowerPoint..."}</span>
90 </div>
91 ),
92 });
93
94 const result = await exportPresentationToPptx(
95 scanResults,
96 slides,
97 currentPresentationTitle ?? "presentation",
98 );
99 const downloadUrl = startDownload(result.blob, result.fileName);
100
101 dismiss();
102
103 toast({
104 title: "Export Complete",
105 description: (
106 <p>
107 {"PowerPoint download has started. If it did not start,"}{" "}
108 <a
109 className="font-medium text-foreground underline underline-offset-4"
110 download={result.fileName}
111 href={downloadUrl}
112 >
113 {"click here"}
114 </a>
115 .
116 </p>
117 ),
118 duration: EXPORT_SUCCESS_TOAST_DURATION_MS,
119 });
120
121 setIsExportDialogOpen(false);
122 } catch (error) {
123 toast({
124 title: "Export Failed",
125 description:
126 error instanceof Error
127 ? error.message
128 : "There was an error exporting your presentation.",
129 variant: "destructive",
130 });
131 console.error("Export error:", error);
132 setIsExporting(false);
133 }
134 setIsExporting(false);
135 };
136
137 return (
138 <Dialog open={isExportDialogOpen} onOpenChange={setIsExportDialogOpen}>
139 <DialogTrigger asChild>
140 <Button
141 variant="ghost"
142 size="sm"
143 className="relative size-9 px-0 sm:h-9 sm:w-auto sm:gap-1.5 sm:px-3"
144 >
145 <span className="sr-only">
146 Export presentation
147 </span>
148 <Download className="size-4 sm:mr-1" />
149 <span className="hidden sm:inline">
150 Export
151 </span>
152 </Button>
153 </DialogTrigger>
154 <DialogContent className="sm:max-w-md">
155 <DialogHeader>
156 <DialogTitle>
157 Export Presentation
158 </DialogTitle>
159 <DialogDescription>
160 Choose a format to export your presentation.
161 </DialogDescription>
162 </DialogHeader>
163
164 <div className="py-4">
165 <Label className="mb-2 block">
166 Export Format
167 </Label>
168 <RadioGroup
169 value="pptx"
170 className="grid gap-4"
171 >
172 <Label
173 htmlFor="pptx"
174 className={`flex cursor-pointer items-start space-x-4 rounded-xl border p-4 transition-all hover:bg-accent hover:text-accent-foreground ${
175 "border-primary bg-accent/50 ring ring-primary"
176 }`}
177 >
178 <RadioGroupItem value="pptx" id="pptx" className="mt-3" />
179 <div className="flex-1 space-y-1">
180 <div className="flex items-center gap-3">
181 <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
182 <Download className="size-5" />
183 </div>
184 <div>
185 <span className="block text-base font-semibold">
186 PowerPoint (.pptx)
187 </span>
188 <p className="text-sm leading-snug text-muted-foreground">
189 Standard PowerPoint file
190 </p>
191 </div>
192 </div>
193 </div>
194 </Label>
195 </RadioGroup>
196 </div>
197
198 <DialogFooter>
199 <Button
200 type="button"
201 variant="secondary"
202 onClick={() => setIsExportDialogOpen(false)}
203 disabled={isExporting}
204 >
205 Cancel
206 </Button>
207 <Button type="button" onClick={handleExport} disabled={isExporting}>
208 {isExporting ? (
209 <>
210 <Loader2 className="mr-2 size-4 animate-spin" />
211 Exporting…
212 </>
213 ) : (
214 "Export to PowerPoint"
215 )}
216 </Button>
217 </DialogFooter>
218 </DialogContent>
219 </Dialog>
220 );
221 }
222
222 lines Plain Text