summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/export-button.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor/export-button.tsx')
-rw-r--r--apps/web/src/components/editor/export-button.tsx672
1 files changed, 336 insertions, 336 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index ec39e7e6..3fa4af7a 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -1,336 +1,336 @@
-"use client";
-
-import { useState } from "react";
-import { TransitionTopIcon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import { Progress } from "@/components/ui/progress";
-import { Checkbox } from "@/components/ui/checkbox";
-import { cn } from "@/utils/ui";
-import {
- getExportMimeType,
- getExportFileExtension,
- downloadBuffer,
-} from "@/lib/export";
-import { Check, Copy, Download, RotateCcw } from "lucide-react";
-import {
- EXPORT_FORMAT_VALUES,
- EXPORT_QUALITY_VALUES,
- type ExportFormat,
- type ExportQuality,
-} from "@/lib/export";
-import {
- Section,
- SectionContent,
- SectionHeader,
- SectionTitle,
-} from "@/components/section";
-import { useEditor } from "@/hooks/use-editor";
-import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
-
-function isExportFormat(value: string): value is ExportFormat {
- return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
-}
-
-function isExportQuality(value: string): value is ExportQuality {
- return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
-}
-
-export function ExportButton() {
- const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActiveOrNull());
- const hasProject = !!activeProject;
-
- const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
- if (!open) {
- editor.project.cancelExport();
- editor.project.clearExportState();
- }
- setIsExportPopoverOpen(open);
- };
-
- return (
- <Popover
- open={isExportPopoverOpen}
- onOpenChange={(open) => handlePopoverOpenChange({ open })}
- >
- <PopoverTrigger asChild>
- <button
- type="button"
- className={cn(
- "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
- hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
- )}
- onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
- disabled={!hasProject}
- onKeyDown={(event) => {
- if (hasProject && (event.key === "Enter" || event.key === " ")) {
- event.preventDefault();
- setIsExportPopoverOpen(true);
- }
- }}
- >
- <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
- <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
- <span className="z-50 text-[0.875rem]">Export</span>
- <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
- <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
- </div>
- </div>
- </button>
- </PopoverTrigger>
- {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
- </Popover>
- );
-}
-
-function ExportPopover({
- onOpenChange,
-}: {
- onOpenChange: (open: boolean) => void;
-}) {
- const editor = useEditor();
- const activeProject = useEditor((e) => e.project.getActive());
- const exportState = useEditor((e) => e.project.getExportState());
- const { isExporting, progress, result: exportResult } = exportState;
- const [format, setFormat] = useState<ExportFormat>(
- DEFAULT_EXPORT_OPTIONS.format,
- );
- const [quality, setQuality] = useState<ExportQuality>(
- DEFAULT_EXPORT_OPTIONS.quality,
- );
- const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
- DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
- );
-
- const handleExport = async () => {
- if (!activeProject) return;
-
- const result = await editor.project.export({
- options: {
- format,
- quality,
- fps: activeProject.settings.fps,
- includeAudio: shouldIncludeAudio,
- },
- });
-
- if (result.cancelled) {
- editor.project.clearExportState();
- return;
- }
-
- if (result.success && result.buffer) {
- downloadBuffer({
- buffer: result.buffer,
- filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
- mimeType: getExportMimeType({ format }),
- });
-
- editor.project.clearExportState();
- onOpenChange(false);
- }
- };
-
- const handleCancel = () => {
- editor.project.cancelExport();
- };
-
- return (
- <PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
- {exportResult && !exportResult.success ? (
- <ExportError
- error={exportResult.error || "Unknown error occurred"}
- onRetry={handleExport}
- />
- ) : (
- <>
- <div className="flex items-center justify-between p-3 border-b">
- <h3 className="font-medium text-sm">
- {isExporting ? "Exporting project" : "Export project"}
- </h3>
- </div>
-
- <div className="flex flex-col gap-4">
- {!isExporting && (
- <>
- <div className="flex flex-col">
- <Section
- collapsible
- defaultOpen={false}
- showTopBorder={false}
- >
- <SectionHeader>
- <SectionTitle>Format</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <RadioGroup
- value={format}
- onValueChange={(value) => {
- if (isExportFormat(value)) {
- setFormat(value);
- }
- }}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="mp4" id="mp4" />
- <Label htmlFor="mp4">
- MP4 (H.264) - Better compatibility
- </Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="webm" id="webm" />
- <Label htmlFor="webm">
- WebM (VP9) - Smaller file size
- </Label>
- </div>
- </RadioGroup>
- </SectionContent>
- </Section>
-
- <Section collapsible defaultOpen={false}>
- <SectionHeader>
- <SectionTitle>Quality</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <RadioGroup
- value={quality}
- onValueChange={(value) => {
- if (isExportQuality(value)) {
- setQuality(value);
- }
- }}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="low" id="low" />
- <Label htmlFor="low">Low - Smallest file size</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="medium" id="medium" />
- <Label htmlFor="medium">Medium - Balanced</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="high" id="high" />
- <Label htmlFor="high">High - Recommended</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="very_high" id="very_high" />
- <Label htmlFor="very_high">
- Very high - Largest file size
- </Label>
- </div>
- </RadioGroup>
- </SectionContent>
- </Section>
-
- <Section collapsible defaultOpen={false}>
- <SectionHeader>
- <SectionTitle>Audio</SectionTitle>
- </SectionHeader>
- <SectionContent>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={shouldIncludeAudio}
- onCheckedChange={(checked) =>
- setShouldIncludeAudio(!!checked)
- }
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
- </div>
- </SectionContent>
- </Section>
- </div>
-
- <div className="p-3 pt-0">
- <Button onClick={handleExport} className="w-full gap-2">
- <Download className="size-4" />
- Export
- </Button>
- </div>
- </>
- )}
-
- {isExporting && (
- <div className="space-y-4 p-3">
- <div className="flex flex-col gap-2">
- <div className="flex items-center justify-between text-center">
- <p className="text-muted-foreground text-sm">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-muted-foreground text-sm">100%</p>
- </div>
- <Progress value={progress * 100} className="w-full" />
- </div>
-
- <Button
- variant="outline"
- className="w-full rounded-md"
- onClick={handleCancel}
- >
- Cancel
- </Button>
- </div>
- )}
- </div>
- </>
- )}
- </PopoverContent>
- );
-}
-
-function ExportError({
- error,
- onRetry,
-}: {
- error: string;
- onRetry: () => void;
-}) {
- const [copied, setCopied] = useState(false);
-
- const handleCopy = async () => {
- await navigator.clipboard.writeText(error);
- setCopied(true);
- setTimeout(() => setCopied(false), 1000);
- };
-
- return (
- <div className="space-y-4 p-3">
- <div className="flex flex-col gap-1.5">
- <p className="text-destructive text-sm font-medium">Export failed</p>
- <p className="text-muted-foreground text-xs">{error}</p>
- </div>
-
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- className="h-8 flex-1 text-xs"
- onClick={handleCopy}
- >
- {copied ? <Check className="text-constructive" /> : <Copy />}
- Copy
- </Button>
- <Button
- variant="outline"
- size="sm"
- className="h-8 flex-1 text-xs"
- onClick={onRetry}
- >
- <RotateCcw />
- Retry
- </Button>
- </div>
- </div>
- );
-}
+"use client";
+
+import { useState } from "react";
+import { TransitionTopIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Progress } from "@/components/ui/progress";
+import { Checkbox } from "@/components/ui/checkbox";
+import { cn } from "@/utils/ui";
+import {
+ getExportMimeType,
+ getExportFileExtension,
+ downloadBuffer,
+} from "@/lib/export";
+import { Check, Copy, Download, RotateCcw } from "lucide-react";
+import {
+ EXPORT_FORMAT_VALUES,
+ EXPORT_QUALITY_VALUES,
+ type ExportFormat,
+ type ExportQuality,
+} from "@/lib/export";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
+
+function isExportFormat(value: string): value is ExportFormat {
+ return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
+}
+
+function isExportQuality(value: string): value is ExportQuality {
+ return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
+}
+
+export function ExportButton() {
+ const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
+ const hasProject = !!activeProject;
+
+ const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
+ if (!open) {
+ editor.project.cancelExport();
+ editor.project.clearExportState();
+ }
+ setIsExportPopoverOpen(open);
+ };
+
+ return (
+ <Popover
+ open={isExportPopoverOpen}
+ onOpenChange={(open) => handlePopoverOpenChange({ open })}
+ >
+ <PopoverTrigger asChild>
+ <button
+ type="button"
+ className={cn(
+ "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
+ hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
+ )}
+ onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
+ disabled={!hasProject}
+ onKeyDown={(event) => {
+ if (hasProject && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ setIsExportPopoverOpen(true);
+ }
+ }}
+ >
+ <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
+ <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
+ <span className="z-50 text-[0.875rem]">Export</span>
+ <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
+ <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
+ </div>
+ </div>
+ </button>
+ </PopoverTrigger>
+ {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
+ </Popover>
+ );
+}
+
+function ExportPopover({
+ onOpenChange,
+}: {
+ onOpenChange: (open: boolean) => void;
+}) {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const exportState = useEditor((e) => e.project.getExportState());
+ const { isExporting, progress, result: exportResult } = exportState;
+ const [format, setFormat] = useState<ExportFormat>(
+ DEFAULT_EXPORT_OPTIONS.format,
+ );
+ const [quality, setQuality] = useState<ExportQuality>(
+ DEFAULT_EXPORT_OPTIONS.quality,
+ );
+ const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
+ DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
+ );
+
+ const handleExport = async () => {
+ if (!activeProject) return;
+
+ const result = await editor.project.export({
+ options: {
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
+ },
+ });
+
+ if (result.cancelled) {
+ editor.project.clearExportState();
+ return;
+ }
+
+ if (result.success && result.buffer) {
+ downloadBuffer({
+ buffer: result.buffer,
+ filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
+ mimeType: getExportMimeType({ format }),
+ });
+
+ editor.project.clearExportState();
+ onOpenChange(false);
+ }
+ };
+
+ const handleCancel = () => {
+ editor.project.cancelExport();
+ };
+
+ return (
+ <PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
+ {exportResult && !exportResult.success ? (
+ <ExportError
+ error={exportResult.error || "Unknown error occurred"}
+ onRetry={handleExport}
+ />
+ ) : (
+ <>
+ <div className="flex items-center justify-between p-3 border-b">
+ <h3 className="font-medium text-sm">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ </div>
+
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col">
+ <Section
+ collapsible
+ defaultOpen={false}
+ showTopBorder={false}
+ >
+ <SectionHeader>
+ <SectionTitle>Format</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <RadioGroup
+ value={format}
+ onValueChange={(value) => {
+ if (isExportFormat(value)) {
+ setFormat(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
+
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader>
+ <SectionTitle>Quality</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) => {
+ if (isExportQuality(value)) {
+ setQuality(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very high - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
+
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+
+ <div className="p-3 pt-0">
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="size-4" />
+ Export
+ </Button>
+ </div>
+ </>
+ )}
+
+ {isExporting && (
+ <div className="space-y-4 p-3">
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground text-sm">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
+
+ <Button
+ variant="outline"
+ className="w-full rounded-md"
+ onClick={handleCancel}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
+ </div>
+ </>
+ )}
+ </PopoverContent>
+ );
+}
+
+function ExportError({
+ error,
+ onRetry,
+}: {
+ error: string;
+ onRetry: () => void;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
+
+ return (
+ <div className="space-y-4 p-3">
+ <div className="flex flex-col gap-1.5">
+ <p className="text-destructive text-sm font-medium">Export failed</p>
+ <p className="text-muted-foreground text-xs">{error}</p>
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 flex-1 text-xs"
+ onClick={handleCopy}
+ >
+ {copied ? <Check className="text-constructive" /> : <Copy />}
+ Copy
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 flex-1 text-xs"
+ onClick={onRetry}
+ >
+ <RotateCcw />
+ Retry
+ </Button>
+ </div>
+ </div>
+ );
+}