diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 4d5a27fc..1b53b6b5 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -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 { diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx deleted file mode 100644 index 0457cb05..00000000 --- a/apps/web/src/components/background-settings.tsx +++ /dev/null @@ -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 ( - - - - - -
-

Background

-
- {tabs.map((tab) => ( - { - // 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} - - ))} -
-
- {activeTab === "color" ? ( - - ) : ( - - )} -
-
- ); -} - -function ColorView({ - selectedColor, - onColorSelect, -}: { - selectedColor: string; - onColorSelect: (color: string) => void; -}) { - return ( -
-
-
-
- -
- {colors.map((color) => ( - onColorSelect(color)} - /> - ))} -
-
- ); -} - -function ColorItem({ - color, - isSelected, - onClick, -}: { - color: string; - isSelected: boolean; - onClick: () => void; -}) { - return ( -
- ); -} - -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 ( -
- {blurLevels.map((blur) => ( -
onBlurSelect(blur.value)} - > - {`Blur -
- - {blur.label} - -
-
- ))} -
- ); -} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx similarity index 93% rename from apps/web/src/components/editor-header.tsx rename to apps/web/src/components/editor/editor-header.tsx index ee5dfc17..ad2a5b5a 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -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"; diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx new file mode 100644 index 00000000..e24d20df --- /dev/null +++ b/apps/web/src/components/editor/export-button.tsx @@ -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 ( + + + + + {hasProject && } + + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const { activeProject } = useProjectStore(); + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality + ); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportResult, setExportResult] = useState(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 ( + + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+ +
+ +
+ {!isExporting && ( + <> +
+ + setFormat(value as ExportFormat)} + > +
+ + +
+
+ + +
+
+
+ + + + setQuality(value as ExportQuality) + } + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + )} + + {isExporting && ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ + +
+ )} + + {exportResult && !exportResult.success && ( +
+
Export failed
+

+ {exportResult.error || "Unknown error occurred"} +

+ +
+ )} +
+ +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx index 393481a5..59403a8d 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -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"; diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index d33b4cce..df1ec54f 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -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, diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 5d522c84..22fc2413 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -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"; diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx similarity index 97% rename from apps/web/src/components/onboarding.tsx rename to apps/web/src/components/editor/onboarding.tsx index ac4b0f04..8ee60690 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/editor/onboarding.tsx @@ -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"; diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx similarity index 60% rename from apps/web/src/components/editor/media-panel/views/base-view.tsx rename to apps/web/src/components/editor/panel-base-view.tsx index 2cd33535..0cfb8f40 100644 --- a/apps/web/src/components/editor/media-panel/views/base-view.tsx +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -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 (
{!tabs || tabs.length === 0 ? ( {children} ) : ( - -
- - {tabs.map((tab) => ( - - {tab.label} - - ))} - + +
+
+ + {tabs.map((tab) => ( + + {tab.label} + + ))} + +
+
- {tabs.map((tab) => ( setIsExpanded(!isExpanded)} > - + {title} diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 744c2be5..139c7005 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -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 ( -
-