summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/export-button.tsx
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/export-button.tsx
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/export-button.tsx')
-rw-r--r--apps/web/src/components/editor/export-button.tsx588
1 files changed, 309 insertions, 279 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index 246e8b6f..f79710bf 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -1,317 +1,347 @@
"use client";
-import { useState } from "react";
-import { TransitionUpIcon } from "../icons";
-import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
-import { Button } from "../ui/button";
-import { Label } from "../ui/label";
-import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
-import { Progress } from "../ui/progress";
-import { Checkbox } from "../ui/checkbox";
-import { cn } from "@/lib/utils";
+import { useState, useRef } from "react";
+import { TransitionTopIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
import {
- exportProject,
- getExportMimeType,
- getExportFileExtension,
- DEFAULT_EXPORT_OPTIONS,
-} from "@/lib/export";
-import { useProjectStore } from "@/stores/project-store";
+ 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 } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
-import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
-import { PropertyGroup } from "./properties-panel/property-item";
+import {
+ EXPORT_FORMAT_VALUES,
+ EXPORT_QUALITY_VALUES,
+ type ExportFormat,
+ type ExportQuality,
+ type ExportResult,
+} from "@/types/export";
+import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
export function ExportButton() {
- const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
- const { activeProject } = useProjectStore();
+ const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
+ const editor = useEditor();
- const handleExport = () => {
- setIsExportPopoverOpen(true);
- };
+ const handleExport = () => {
+ setIsExportPopoverOpen(true);
+ };
- const hasProject = !!activeProject;
+ const hasProject = !!editor.project.getActive();
- return (
- <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
- <PopoverTrigger asChild>
- <button
- type="button"
- className={cn(
- "flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200",
- hasProject
- ? "cursor-pointer hover:brightness-95"
- : "cursor-not-allowed opacity-50"
- )}
- onClick={hasProject ? handleExport : undefined}
- disabled={!hasProject}
- onKeyDown={(event) => {
- if (hasProject && (event.key === "Enter" || event.key === " ")) {
- event.preventDefault();
- handleExport();
- }
- }}
- >
- <div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
- <TransitionUpIcon className="z-50" />
- <span className="text-[0.875rem] z-50">Export</span>
- <div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
- <div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
- </div>
- </div>
- </button>
- </PopoverTrigger>
- {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
- </Popover>
- );
+ return (
+ <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
+ <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 hover:brightness-105"
+ : "cursor-not-allowed opacity-50",
+ )}
+ onClick={hasProject ? handleExport : undefined}
+ disabled={!hasProject}
+ onKeyDown={(event) => {
+ if (hasProject && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ handleExport();
+ }
+ }}
+ >
+ <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,
}: {
- onOpenChange: (open: boolean) => void;
+ onOpenChange: (open: boolean) => void;
}) {
- const { activeProject } = useProjectStore();
- const [format, setFormat] = useState<ExportFormat>(
- DEFAULT_EXPORT_OPTIONS.format
- );
- const [quality, setQuality] = useState<ExportQuality>(
- DEFAULT_EXPORT_OPTIONS.quality
- );
- const [includeAudio, setIncludeAudio] = useState<boolean>(
- DEFAULT_EXPORT_OPTIONS.includeAudio || true
- );
- const [isExporting, setIsExporting] = useState(false);
- const [progress, setProgress] = useState(0);
- const [exportResult, setExportResult] = useState<ExportResult | null>(null);
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const [format, setFormat] = useState<ExportFormat>(
+ DEFAULT_EXPORT_OPTIONS.format,
+ );
+ const [quality, setQuality] = useState<ExportQuality>(
+ DEFAULT_EXPORT_OPTIONS.quality,
+ );
+ const [includeAudio, setIncludeAudio] = useState<boolean>(
+ DEFAULT_EXPORT_OPTIONS.includeAudio || true,
+ );
+ const [isExporting, setIsExporting] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [exportResult, setExportResult] = useState<ExportResult | null>(null);
+ const cancelRequestedRef = useRef(false);
+
+ const handleExport = async () => {
+ if (!activeProject) return;
+
+ cancelRequestedRef.current = false;
+ setIsExporting(true);
+ setProgress(0);
+ setExportResult(null);
+
+ const result = await editor.project.export({
+ options: {
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio,
+ onProgress: ({ progress }) => setProgress(progress),
+ onCancel: () => cancelRequestedRef.current,
+ },
+ });
- const handleExport = async () => {
- if (!activeProject) return;
+ setIsExporting(false);
- setIsExporting(true);
- setProgress(0);
- setExportResult(null);
+ if (result.cancelled) {
+ setExportResult(null);
+ setProgress(0);
+ return;
+ }
- const result = await exportProject({
- format,
- quality,
- fps: activeProject.fps,
- includeAudio,
- onProgress: setProgress,
- onCancel: () => false, // TODO: Add cancel functionality
- });
+ setExportResult(result);
- setIsExporting(false);
- setExportResult(result);
+ if (result.success && result.buffer) {
+ const mimeType = getExportMimeType({ format });
+ const extension = getExportFileExtension({ format });
+ const blob = new Blob([result.buffer], { type: mimeType });
+ const url = URL.createObjectURL(blob);
- if (result.success && result.buffer) {
- // Download the file
- const mimeType = getExportMimeType(format);
- const extension = getExportFileExtension(format);
- const blob = new Blob([result.buffer], { type: mimeType });
- const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${activeProject.metadata.name}${extension}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
- const a = document.createElement("a");
- a.href = url;
- a.download = `${activeProject.name}${extension}`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ onOpenChange(false);
+ setExportResult(null);
+ setProgress(0);
+ }
+ };
- onOpenChange(false);
- setExportResult(null);
- setProgress(0);
- }
- };
+ const handleClose = () => {
+ if (!isExporting) {
+ onOpenChange(false);
+ setExportResult(null);
+ setProgress(0);
+ }
+ };
- const handleClose = () => {
- if (!isExporting) {
- onOpenChange(false);
- setExportResult(null);
- setProgress(0);
- }
- };
+ const handleCancel = () => {
+ cancelRequestedRef.current = true;
+ };
- return (
- <PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
- <>
- {exportResult && !exportResult.success ? (
- <ExportError
- error={exportResult.error || "Unknown error occurred"}
- onRetry={handleExport}
- />
- ) : (
- <>
- <div className="flex items-center justify-between">
- <h3 className=" font-medium">
- {isExporting ? "Exporting project" : "Export project"}
- </h3>
- <Button variant="text" size="icon" onClick={handleClose}>
- <X className="!size-5 text-foreground/85" />
- </Button>
- </div>
+ return (
+ <PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
+ {exportResult && !exportResult.success ? (
+ <ExportError
+ error={exportResult.error || "Unknown error occurred"}
+ onRetry={handleExport}
+ />
+ ) : (
+ <>
+ <div className="flex items-center justify-between">
+ <h3 className="font-medium">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ <Button variant="text" size="icon" onClick={handleClose}>
+ <X className="text-foreground/85 !size-5" />
+ </Button>
+ </div>
- <div className="flex flex-col gap-4">
- {!isExporting && (
- <>
- <div className="flex flex-col gap-3">
- <PropertyGroup
- title="Format"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={format}
- onValueChange={(value) =>
- setFormat(value as ExportFormat)
- }
- >
- <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>
- </PropertyGroup>
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col gap-3">
+ <PropertyGroup
+ title="Format"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <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>
+ </PropertyGroup>
- <PropertyGroup
- title="Quality"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={quality}
- onValueChange={(value) =>
- setQuality(value as ExportQuality)
- }
- >
- <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>
- </PropertyGroup>
+ <PropertyGroup
+ title="Quality"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <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>
+ </PropertyGroup>
- <PropertyGroup
- title="Audio"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) =>
- setIncludeAudio(!!checked)
- }
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
- </div>
- </PropertyGroup>
- </div>
+ <PropertyGroup
+ title="Audio"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) =>
+ setIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </PropertyGroup>
+ </div>
- <Button onClick={handleExport} className="w-full gap-2">
- <Download className="w-4 h-4" />
- Export
- </Button>
- </>
- )}
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="size-4" />
+ Export
+ </Button>
+ </>
+ )}
- {isExporting && (
- <div className="space-y-4">
- <div className="flex flex-col">
- <div className="text-center flex items-center justify-between">
- <p className="text-sm text-muted-foreground mb-2">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-sm text-muted-foreground mb-2">100%</p>
- </div>
- <Progress value={progress * 100} className="w-full" />
- </div>
+ {isExporting && (
+ <div className="space-y-4">
+ <div className="flex flex-col">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground mb-2 text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground mb-2 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 isExportFormat(value: string): value is ExportFormat {
+ return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
+}
- <Button
- variant="outline"
- className="rounded-md w-full"
- onClick={() => {}}
- >
- Cancel
- </Button>
- </div>
- )}
- </div>
- </>
- )}
- </>
- </PopoverContent>
- );
+function isExportQuality(value: string): value is ExportQuality {
+ return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
}
function ExportError({
- error,
- onRetry,
+ error,
+ onRetry,
}: {
- error: string;
- onRetry: () => void;
+ error: string;
+ onRetry: () => void;
}) {
- const [copied, setCopied] = useState(false);
+ const [copied, setCopied] = useState(false);
- const handleCopy = async () => {
- await navigator.clipboard.writeText(error);
- setCopied(true);
- setTimeout(() => setCopied(false), 1000);
- };
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
- return (
- <div className="space-y-4">
- <div className="flex flex-col gap-1.5">
- <p className="text-sm font-medium text-red-400">Export failed</p>
- <p className="text-xs text-muted-foreground">
- {error}
- </p>
- </div>
+ return (
+ <div className="space-y-4">
+ <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="flex-1 text-xs h-8"
- onClick={handleCopy}
- >
- {copied ? <Check className="text-green-500" /> : <Copy />}
- Copy
- </Button>
- <Button
- variant="outline"
- size="sm"
- className="flex-1 text-xs h-8"
- onClick={onRetry}
- >
- <RotateCcw />
- Retry
- </Button>
- </div>
- </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>
+ );
}