diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-27 00:55:15 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:56:48 +0200 |
| commit | 4d127a038bf768eded68aae3cfa5c1b12241bdac (patch) | |
| tree | 16bbb801caf6770546cff4fdb37b6408ec9ead98 /apps/web/src/components/editor/export-button.tsx | |
| parent | 82817cb50cb0487350573d6129130aec705f924b (diff) | |
| download | opencut-4d127a038bf768eded68aae3cfa5c1b12241bdac.tar.gz | |
chore: normalize line endings
Diffstat (limited to 'apps/web/src/components/editor/export-button.tsx')
| -rw-r--r-- | apps/web/src/components/editor/export-button.tsx | 672 |
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>
+ );
+}
|
