another huge refactor
This commit is contained in:
parent
b459b46802
commit
adc8cb37fc
|
|
@ -6,17 +6,17 @@ import {
|
|||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "../../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../../components/editor/media-panel";
|
||||
import { PropertiesPanel } from "../../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor-header";
|
||||
} from "@/components/ui/resizable";
|
||||
import { MediaPanel } from "@/components/editor/media-panel";
|
||||
import { PropertiesPanel } from "@/components/editor/properties-panel";
|
||||
import { Timeline } from "@/components/editor/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorProvider } from "@/components/editor-provider";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { usePlaybackControls } from "@/hooks/use-playback-controls";
|
||||
import { Onboarding } from "@/components/onboarding";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
|
||||
export default function Editor() {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -1,184 +0,0 @@
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { BackgroundIcon } from "./icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { PipetteIcon } from "lucide-react";
|
||||
|
||||
type BackgroundTab = "color" | "blur";
|
||||
|
||||
export function BackgroundSettings() {
|
||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||
|
||||
// ✅ Good: derive activeTab from activeProject during rendering
|
||||
const activeTab = activeProject?.backgroundType || "color";
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
updateBackgroundType("color", { backgroundColor: color });
|
||||
};
|
||||
|
||||
const handleBlurSelect = (blurIntensity: number) => {
|
||||
updateBackgroundType("blur", { blurIntensity });
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Color",
|
||||
value: "color",
|
||||
},
|
||||
{
|
||||
label: "Blur",
|
||||
value: "blur",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="size-4! border border-muted-foreground"
|
||||
>
|
||||
<BackgroundIcon className="size-3!" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-64 overflow-hidden p-0">
|
||||
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
|
||||
<h2 className="text-sm">Background</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{tabs.map((tab) => (
|
||||
<span
|
||||
key={tab.value}
|
||||
onClick={() => {
|
||||
// Switch to the background type when clicking tabs
|
||||
if (tab.value === "color") {
|
||||
updateBackgroundType("color", {
|
||||
backgroundColor:
|
||||
activeProject?.backgroundColor || "#000000",
|
||||
});
|
||||
} else {
|
||||
updateBackgroundType("blur", {
|
||||
blurIntensity: activeProject?.blurIntensity || 8,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground cursor-pointer",
|
||||
activeTab === tab.value && "text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "color" ? (
|
||||
<ColorView
|
||||
selectedColor={activeProject?.backgroundColor || "#000000"}
|
||||
onColorSelect={handleColorSelect}
|
||||
/>
|
||||
) : (
|
||||
<BlurView
|
||||
selectedBlur={activeProject?.blurIntensity || 8}
|
||||
onBlurSelect={handleBlurSelect}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorView({
|
||||
selectedColor,
|
||||
onColorSelect,
|
||||
}: {
|
||||
selectedColor: string;
|
||||
onColorSelect: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-linear-to-b from-popover to-transparent pointer-events-none" />
|
||||
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
|
||||
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
|
||||
<PipetteIcon className="size-4" />
|
||||
</div>
|
||||
{colors.map((color) => (
|
||||
<ColorItem
|
||||
key={color}
|
||||
color={color}
|
||||
isSelected={color === selectedColor}
|
||||
onClick={() => onColorSelect(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorItem({
|
||||
color,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
color: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
|
||||
isSelected && "border-2 border-primary"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurView({
|
||||
selectedBlur,
|
||||
onBlurSelect,
|
||||
}: {
|
||||
selectedBlur: number;
|
||||
onBlurSelect: (blurIntensity: number) => void;
|
||||
}) {
|
||||
const blurLevels = [
|
||||
{ label: "Light", value: 4 },
|
||||
{ label: "Medium", value: 8 },
|
||||
{ label: "Heavy", value: 18 },
|
||||
];
|
||||
const blurImage =
|
||||
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
|
||||
{blurLevels.map((blur) => (
|
||||
<div
|
||||
key={blur.value}
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
|
||||
selectedBlur === blur.value && "border-2 border-primary"
|
||||
)}
|
||||
onClick={() => onBlurSelect(blur.value)}
|
||||
>
|
||||
<Image
|
||||
src={blurImage}
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
/>
|
||||
<div className="absolute bottom-1 left-1 right-1 text-center">
|
||||
<span className="text-xs text-white bg-black/50 px-1 rounded">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "./ui/button";
|
||||
import { Button } from "../ui/button";
|
||||
import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { HeaderBase } from "../header-base";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
|
||||
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -12,10 +12,10 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
} from "../ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { RenameProjectDialog } from "./rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "./delete-project-dialog";
|
||||
import { RenameProjectDialog } from "../rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "../delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
"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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
exportProject,
|
||||
getExportMimeType,
|
||||
getExportFileExtension,
|
||||
DEFAULT_EXPORT_OPTIONS,
|
||||
} from "@/lib/export";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||
import { PropertyGroup } from "./properties-panel/property-item";
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
const handleExport = () => {
|
||||
setIsExportPopoverOpen(true);
|
||||
};
|
||||
|
||||
const hasProject = !!activeProject;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportPopover({
|
||||
onOpenChange,
|
||||
}: {
|
||||
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 [isExporting, setIsExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!activeProject) return;
|
||||
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
setExportResult(null);
|
||||
|
||||
const result = await exportProject({
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.fps,
|
||||
onProgress: setProgress,
|
||||
onCancel: () => false, // TODO: Add cancel functionality
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
setExportResult(result);
|
||||
|
||||
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.name}${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverContent className="w-80 mr-4 flex flex-col gap-3">
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="w-4 h-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>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-md w-full"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportResult && !exportResult.success && (
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-red-600 font-medium">Export failed</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{exportResult.error || "Unknown error occurred"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setExportResult(null)}>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||
import { BaseView } from "./base-view";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { Language, LanguageSelect } from "@/components/language-select";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { BaseView } from "./base-view";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { BaseView } from "./base-view";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
|
@ -2,9 +2,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface BaseViewProps {
|
||||
interface PanelBaseViewProps {
|
||||
children?: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
tabs?: {
|
||||
value: string;
|
||||
label: string;
|
||||
|
|
@ -28,29 +30,38 @@ function ViewContent({
|
|||
);
|
||||
}
|
||||
|
||||
export function BaseView({
|
||||
export function PanelBaseView({
|
||||
children,
|
||||
defaultTab,
|
||||
value,
|
||||
onValueChange,
|
||||
tabs,
|
||||
className = "",
|
||||
ref,
|
||||
}: BaseViewProps) {
|
||||
}: PanelBaseViewProps) {
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<Tabs
|
||||
defaultValue={defaultTab}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-panel">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "./ui/button";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
} from "../ui/dropdown-menu";
|
||||
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
|
||||
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
|
||||
|
||||
|
|
@ -538,6 +538,7 @@ export function PreviewPanel() {
|
|||
activeProject?.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
// Blit offscreen to visible canvas
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ interface PropertyGroupProps {
|
|||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
}
|
||||
|
||||
export function PropertyGroup({
|
||||
|
|
@ -64,6 +65,7 @@ export function PropertyGroup({
|
|||
children,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
titleClassName,
|
||||
}: PropertyGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
|
|
@ -73,7 +75,7 @@ export function PropertyGroup({
|
|||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<PropertyItemLabel className="cursor-pointer">
|
||||
<PropertyItemLabel className={cn("cursor-pointer", titleClassName)}>
|
||||
{title}
|
||||
</PropertyItemLabel>
|
||||
<ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} />
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
|||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch"; // Add Switch import
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useState, useRef } from "react";
|
||||
import { PanelBaseView } from "@/components/editor/panel-base-view";
|
||||
import {
|
||||
TEXT_PROPERTIES_TABS,
|
||||
isTextPropertiesTab,
|
||||
useTextPropertiesStore,
|
||||
} from "@/stores/text-properties-store";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
|
|
@ -22,6 +28,7 @@ export function TextProperties({
|
|||
trackId: string;
|
||||
}) {
|
||||
const { updateTextElement } = useTimelineStore();
|
||||
const { activeTab, setActiveTab } = useTextPropertiesStore();
|
||||
|
||||
// Local state for input values to allow temporary empty/invalid states
|
||||
const [fontSizeInput, setFontSizeInput] = useState(
|
||||
|
|
@ -105,199 +112,232 @@ export function TextProperties({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-18 resize-none bg-background/50"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
||||
}
|
||||
/>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, { fontFamily: value })
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={element.fontWeight === "bold" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
<PanelBaseView
|
||||
defaultTab="transform"
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
if (isTextPropertiesTab(v)) setActiveTab(v);
|
||||
}}
|
||||
tabs={TEXT_PROPERTIES_TABS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
content:
|
||||
t.value === "transform" ? (
|
||||
<div className="space-y-6"></div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-18 resize-none bg-background/50"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
variant={element.fontStyle === "italic" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic" ? "normal" : "italic",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "underline" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
>
|
||||
U
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "line-through"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
>
|
||||
S
|
||||
</Button>
|
||||
/>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontFamily: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={
|
||||
element.fontWeight === "bold" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.fontStyle === "italic" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "underline"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
>
|
||||
U
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "line-through"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
>
|
||||
S
|
||||
</Button>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontSize: value,
|
||||
});
|
||||
setFontSizeInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={fontSizeInput}
|
||||
min={8}
|
||||
max={300}
|
||||
onChange={(e) => handleFontSizeChange(e.target.value)}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Input
|
||||
type="color"
|
||||
value={element.color || "#ffffff"}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
updateTextElement(trackId, element.id, { color });
|
||||
}}
|
||||
className="w-full cursor-pointer rounded-full"
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Opacity</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.opacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
opacity: value / 100,
|
||||
});
|
||||
setOpacityInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={opacityInput}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<div className="flex items-center justify-between">
|
||||
<PropertyItemLabel>Background</PropertyItemLabel>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="transparent-bg-toggle"
|
||||
checked={element.backgroundColor === "transparent"}
|
||||
onCheckedChange={handleTransparentToggle}
|
||||
/>
|
||||
<label
|
||||
htmlFor="transparent-bg-toggle"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Transparent
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<PropertyItemValue>
|
||||
<Input
|
||||
type="color"
|
||||
value={
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current
|
||||
: element.backgroundColor || "#000000"
|
||||
}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
className="w-full cursor-pointer rounded-full"
|
||||
disabled={element.backgroundColor === "transparent"}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, { fontSize: value });
|
||||
setFontSizeInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={fontSizeInput}
|
||||
min={8}
|
||||
max={300}
|
||||
onChange={(e) => handleFontSizeChange(e.target.value)}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Input
|
||||
type="color"
|
||||
value={element.color || "#ffffff"}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
updateTextElement(trackId, element.id, { color });
|
||||
}}
|
||||
className="w-full cursor-pointer rounded-full"
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<div className="flex items-center justify-between">
|
||||
<PropertyItemLabel>Background</PropertyItemLabel>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="transparent-bg-toggle"
|
||||
checked={element.backgroundColor === "transparent"}
|
||||
onCheckedChange={handleTransparentToggle}
|
||||
/>
|
||||
<label htmlFor="transparent-bg-toggle" className="text-sm font-medium">
|
||||
Transparent
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<PropertyItemValue>
|
||||
<Input
|
||||
type="color"
|
||||
value={
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current
|
||||
: element.backgroundColor || "#000000"
|
||||
}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
className="w-full cursor-pointer rounded-full"
|
||||
disabled={element.backgroundColor === "transparent"}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Opacity</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.opacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
opacity: value / 100,
|
||||
});
|
||||
setOpacityInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={opacityInput}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TransitionUpIcon } from "./icons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "./ui/form";
|
||||
import {
|
||||
exportWaitlistSchema,
|
||||
type ExportWaitlistInput,
|
||||
type ExportWaitlistResponse,
|
||||
} from "@/lib/schemas/waitlist";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
|
||||
const handleExport = () => {
|
||||
setIsExportDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] cursor-pointer hover:brightness-95 transition-all duration-200"
|
||||
onClick={handleExport}
|
||||
onKeyDown={(event) => {
|
||||
if (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 (soon)</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>
|
||||
<ExportDialog
|
||||
isOpen={isExportDialogOpen}
|
||||
onOpenChange={setIsExportDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const form = useForm<ExportWaitlistInput>({ defaultValues: { email: "" } });
|
||||
const { isSubmitting } = form.formState;
|
||||
const [serverMessage, setServerMessage] = useState<{
|
||||
text: string;
|
||||
type: "success" | "error";
|
||||
} | null>(null);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
const parsed = exportWaitlistSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) {
|
||||
const fieldName =
|
||||
(issue.path[0] as keyof ExportWaitlistInput) || "email";
|
||||
form.setError(fieldName, { type: "zod", message: issue.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setServerMessage(null);
|
||||
const { email } = parsed.data;
|
||||
const response = await fetch("/api/waitlist/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setServerMessage({
|
||||
text: "Something went wrong. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data: ExportWaitlistResponse = await response.json();
|
||||
if (data.success && data.alreadySubscribed) {
|
||||
setServerMessage({
|
||||
text: "You're already on the list.",
|
||||
type: "success",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.success) {
|
||||
setServerMessage({
|
||||
text: "You're on the list. We'll email you when it's ready.",
|
||||
type: "success",
|
||||
});
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
setServerMessage({
|
||||
text: "Couldn't add your email. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<DialogDescription>
|
||||
Export isn't ready yet. we're building a custom pipeline to make it
|
||||
great.
|
||||
</DialogDescription>
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-5" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
We'll let you know once export is ready.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Sending..." : "Notify me"}
|
||||
</Button>
|
||||
</div>
|
||||
{serverMessage ? (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"text-xs",
|
||||
serverMessage.type === "success" ? "text-green-600" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
{serverMessage.text}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,13 +12,13 @@ const Progress = React.forwardRef<
|
|||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
className="h-full w-full flex-1 bg-foreground transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import {
|
||||
Output,
|
||||
Mp4OutputFormat,
|
||||
WebMOutputFormat,
|
||||
BufferTarget,
|
||||
CanvasSource,
|
||||
QUALITY_LOW,
|
||||
QUALITY_MEDIUM,
|
||||
QUALITY_HIGH,
|
||||
QUALITY_VERY_HIGH,
|
||||
} from "mediabunny";
|
||||
import { renderTimelineFrame } from "./timeline-renderer";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/stores/project-store";
|
||||
import { ExportOptions, ExportResult } from "@/types/export";
|
||||
|
||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
||||
format: "mp4",
|
||||
quality: "high",
|
||||
};
|
||||
|
||||
const qualityMap = {
|
||||
low: QUALITY_LOW,
|
||||
medium: QUALITY_MEDIUM,
|
||||
high: QUALITY_HIGH,
|
||||
very_high: QUALITY_VERY_HIGH,
|
||||
};
|
||||
|
||||
export async function exportProject(
|
||||
options: ExportOptions
|
||||
): Promise<ExportResult> {
|
||||
const { format, quality, fps, onProgress, onCancel } = options;
|
||||
|
||||
try {
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const projectStore = useProjectStore.getState();
|
||||
|
||||
const { tracks, getTotalDuration } = timelineStore;
|
||||
const { mediaFiles } = mediaStore;
|
||||
const { activeProject } = projectStore;
|
||||
|
||||
if (!activeProject) {
|
||||
return { success: false, error: "No active project" };
|
||||
}
|
||||
|
||||
const duration = getTotalDuration();
|
||||
if (duration === 0) {
|
||||
return { success: false, error: "Project is empty" };
|
||||
}
|
||||
|
||||
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
|
||||
const canvasSize = activeProject.canvasSize;
|
||||
|
||||
const outputFormat =
|
||||
format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
||||
|
||||
// BufferTarget for smaller files, StreamTarget for larger ones
|
||||
// TODO: Implement StreamTarget
|
||||
const output = new Output({
|
||||
format: outputFormat,
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
// Canvas for rendering
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasSize.width;
|
||||
canvas.height = canvasSize.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
return { success: false, error: "Failed to create canvas context" };
|
||||
}
|
||||
|
||||
const videoSource = new CanvasSource(canvas, {
|
||||
codec: format === "webm" ? "vp9" : "avc", // VP9 for WebM, H.264 for MP4
|
||||
bitrate: qualityMap[quality],
|
||||
});
|
||||
|
||||
output.addVideoTrack(videoSource, { frameRate: exportFps });
|
||||
|
||||
// Start the output
|
||||
await output.start();
|
||||
|
||||
const totalFrames = Math.ceil(duration * exportFps);
|
||||
let cancelled = false;
|
||||
|
||||
// Render each frame
|
||||
for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
|
||||
// Check for cancellation
|
||||
if (onCancel?.()) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const time = frameIndex / exportFps;
|
||||
|
||||
await renderTimelineFrame({
|
||||
ctx,
|
||||
time,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
projectCanvasSize: canvasSize,
|
||||
});
|
||||
|
||||
const frameDuration = 1 / exportFps;
|
||||
await videoSource.add(time, frameDuration);
|
||||
|
||||
onProgress?.(frameIndex / totalFrames);
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
await output.cancel();
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
videoSource.close();
|
||||
await output.finalize();
|
||||
onProgress?.(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
buffer: output.target.buffer || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown export error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getExportMimeType(format: "mp4" | "webm"): string {
|
||||
return format === "webm" ? "video/webm" : "video/mp4";
|
||||
}
|
||||
|
||||
export function getExportFileExtension(format: "mp4" | "webm"): string {
|
||||
return `.${format}`;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ export interface RenderContext {
|
|||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
backgroundColor?: string;
|
||||
projectCanvasSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export async function renderTimelineFrame({
|
||||
|
|
@ -20,6 +21,7 @@ export async function renderTimelineFrame({
|
|||
tracks,
|
||||
mediaFiles,
|
||||
backgroundColor,
|
||||
projectCanvasSize,
|
||||
}: RenderContext): Promise<void> {
|
||||
// Background
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
|
@ -28,8 +30,10 @@ export async function renderTimelineFrame({
|
|||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
const scaleX = 1;
|
||||
const scaleY = 1;
|
||||
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
|
||||
const scaleY = projectCanvasSize
|
||||
? canvasHeight / projectCanvasSize.height
|
||||
: 1;
|
||||
const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
|
||||
const active: Array<{
|
||||
track: TimelineTrack;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type TextPropertiesTab = "transform" | "style";
|
||||
|
||||
export interface TextPropertiesTabMeta {
|
||||
value: TextPropertiesTab;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
|
||||
{ value: "transform", label: "Transform" },
|
||||
{ value: "style", label: "Style" },
|
||||
] as const;
|
||||
|
||||
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
|
||||
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
|
||||
}
|
||||
|
||||
interface TextPropertiesState {
|
||||
activeTab: TextPropertiesTab;
|
||||
setActiveTab: (tab: TextPropertiesTab) => void;
|
||||
}
|
||||
|
||||
export const useTextPropertiesStore = create<TextPropertiesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: "transform",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: "text-properties" }
|
||||
)
|
||||
);
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export type ExportFormat = "mp4" | "webm";
|
||||
export type ExportQuality = "low" | "medium" | "high" | "very_high";
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
fps?: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
onCancel?: () => boolean;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
buffer?: ArrayBuffer;
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
Loading…
Reference in New Issue