summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-27 00:52:39 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-27 00:52:39 +0200
commitadc8cb37fcec21afac848b1e5dd724394c344050 (patch)
tree9157bc1973840b00a96912d42f6741352abedbc4 /apps/web/src/components/editor
parentb459b46802a760a4a81ce91fdf24390e07375fb0 (diff)
downloadopencut-adc8cb37fcec21afac848b1e5dd724394c344050.tar.gz
another huge refactor
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/editor-header.tsx139
-rw-r--r--apps/web/src/components/editor/export-button.tsx246
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/views/settings.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx2
-rw-r--r--apps/web/src/components/editor/onboarding.tsx138
-rw-r--r--apps/web/src/components/editor/panel-base-view.tsx (renamed from apps/web/src/components/editor/media-panel/views/base-view.tsx)37
-rw-r--r--apps/web/src/components/editor/panel-preset-selector.tsx91
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx1
-rw-r--r--apps/web/src/components/editor/properties-panel/property-item.tsx4
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx412
11 files changed, 871 insertions, 203 deletions
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
new file mode 100644
index 00000000..ad2a5b5a
--- /dev/null
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { Button } from "../ui/button";
+import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react";
+import { HeaderBase } from "../header-base";
+import { useProjectStore } from "@/stores/project-store";
+import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
+import { useState } from "react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import Link from "next/link";
+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";
+import { PanelPresetSelector } from "./panel-preset-selector";
+import { ExportButton } from "./export-button";
+
+export function EditorHeader() {
+ const { activeProject, renameProject, deleteProject } = useProjectStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const router = useRouter();
+ const { theme, setTheme } = useTheme();
+
+ const handleNameSave = async (newName: string) => {
+ console.log("handleNameSave", newName);
+ if (activeProject && newName.trim() && newName !== activeProject.name) {
+ try {
+ await renameProject(activeProject.id, newName.trim());
+ setIsRenameDialogOpen(false);
+ } catch (error) {
+ console.error("Failed to rename project:", error);
+ }
+ }
+ };
+
+ const handleDelete = () => {
+ if (activeProject) {
+ deleteProject(activeProject.id);
+ setIsDeleteDialogOpen(false);
+ router.push("/projects");
+ }
+ };
+
+ const leftContent = (
+ <div className="flex items-center gap-2">
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="secondary"
+ className="h-auto py-1.5 px-2.5 flex items-center justify-center"
+ >
+ <ChevronDown className="text-muted-foreground" />
+ <span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start" className="w-40 z-100">
+ <Link href="/projects">
+ <DropdownMenuItem className="flex items-center gap-1.5">
+ <ArrowLeft className="h-4 w-4" />
+ Projects
+ </DropdownMenuItem>
+ </Link>
+ <DropdownMenuItem
+ className="flex items-center gap-1.5"
+ onClick={() => setIsRenameDialogOpen(true)}
+ >
+ <SquarePen className="h-4 w-4" />
+ Rename project
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ variant="destructive"
+ className="flex items-center gap-1.5"
+ onClick={() => setIsDeleteDialogOpen(true)}
+ >
+ <Trash className="h-4 w-4" />
+ Delete Project
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem asChild>
+ <Link
+ href="https://discord.gg/zmR9N35cjK"
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5"
+ >
+ <FaDiscord className="h-4 w-4" />
+ Discord
+ </Link>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ onConfirm={handleNameSave}
+ projectName={activeProject?.name || ""}
+ />
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ onConfirm={handleDelete}
+ projectName={activeProject?.name || ""}
+ />
+ </div>
+ );
+
+ const rightContent = (
+ <nav className="flex items-center gap-2">
+ <PanelPresetSelector />
+ <KeyboardShortcutsHelp />
+ <ExportButton />
+ <Button
+ size="icon"
+ variant="text"
+ className="h-7"
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
+ >
+ <Sun className="!size-[1.1rem]" />
+ <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
+ </Button>
+ </nav>
+ );
+
+ return (
+ <HeaderBase
+ leftContent={leftContent}
+ rightContent={rightContent}
+ className="bg-background h-[3.2rem] px-3 items-center mt-0.5"
+ />
+ );
+}
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 (
+ <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>
+ );
+}
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/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
new file mode 100644
index 00000000..8ee60690
--- /dev/null
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+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";
+
+export function Onboarding() {
+ const [step, setStep] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
+ if (!hasSeenOnboarding) {
+ setIsOpen(true);
+ }
+ }, []);
+
+ const handleNext = () => {
+ setStep(step + 1);
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ localStorage.setItem("hasSeenOnboarding", "true");
+ };
+
+ const getStepTitle = () => {
+ switch (step) {
+ case 0:
+ return "Welcome to OpenCut Beta! 🎉";
+ case 1:
+ return "⚠️ This is a super early beta!";
+ case 2:
+ return "🦋 Have fun testing!";
+ default:
+ return "OpenCut Onboarding";
+ }
+ };
+
+ const renderStepContent = () => {
+ switch (step) {
+ case 0:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title="Welcome to OpenCut Beta! 🎉" />
+ <Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 1:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title={getStepTitle()} />
+ <Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
+ <Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" />
+ <Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
+ </div>
+ <NextButton onClick={handleNext}>Next</NextButton>
+ </div>
+ );
+ case 2:
+ return (
+ <div className="space-y-5">
+ <div className="space-y-3">
+ <Title title={getStepTitle()} />
+ <Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
+ </div>
+ <NextButton onClick={handleClose}>Finish</NextButton>
+ </div>
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleClose}>
+ <DialogContent className="sm:max-w-[425px] !outline-none pt-2">
+ <DialogTitle>
+ <span className="sr-only">{getStepTitle()}</span>
+ </DialogTitle>
+ {renderStepContent()}
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+function Title({ title }: { title: string }) {
+ return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
+}
+
+function Subtitle({ subtitle }: { subtitle: string }) {
+ return <h3 className="text-lg font-medium">{subtitle}</h3>;
+}
+
+function Description({ description }: { description: string }) {
+ return (
+ <div className="text-muted-foreground">
+ <ReactMarkdown
+ components={{
+ p: ({ children }) => <p className="mb-0">{children}</p>,
+ a: ({ href, children }) => (
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-foreground hover:text-foreground/80 underline"
+ >
+ {children}
+ </a>
+ ),
+ }}
+ >
+ {description}
+ </ReactMarkdown>
+ </div>
+ );
+}
+
+function NextButton({
+ children,
+ onClick,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+}) {
+ return (
+ <Button onClick={onClick} variant="default" className="w-full">
+ {children}
+ <ArrowRightIcon className="w-4 h-4" />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/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 (
<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}
diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx
new file mode 100644
index 00000000..e6ae97b3
--- /dev/null
+++ b/apps/web/src/components/editor/panel-preset-selector.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { Button } from "../ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
+import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
+
+const PRESET_LABELS: Record<PanelPreset, string> = {
+ default: "Default",
+ media: "Media",
+ inspector: "Inspector",
+ "vertical-preview": "Vertical Preview",
+};
+
+const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
+ default: "Media, preview, and inspector on top row, timeline on bottom",
+ media: "Full height media on left, preview and inspector on top row",
+ inspector: "Full height inspector on right, media and preview on top row",
+ "vertical-preview": "Full height preview on right for vertical videos",
+};
+
+export function PanelPresetSelector() {
+ const { activePreset, setActivePreset, resetPreset } = usePanelStore();
+
+ const handlePresetChange = (preset: PanelPreset) => {
+ setActivePreset(preset);
+ };
+
+ const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
+ event.stopPropagation();
+ resetPreset(preset);
+ };
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="secondary"
+ size="sm"
+ className="h-8 px-2 flex items-center gap-1 text-xs"
+ title="Panel Presets"
+ >
+ <LayoutPanelTop className="h-4 w-4" />
+ <ChevronDown className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start" className="w-64">
+ <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
+ Panel Presets
+ </div>
+ <DropdownMenuSeparator />
+ {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
+ <DropdownMenuItem
+ key={preset}
+ onClick={() => handlePresetChange(preset)}
+ className="flex items-start justify-between gap-2 py-2 cursor-pointer"
+ >
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-sm">
+ {PRESET_LABELS[preset]}
+ </span>
+ {activePreset === preset && (
+ <div className="h-2 w-2 rounded-full bg-primary" />
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground mt-0.5 leading-tight">
+ {PRESET_DESCRIPTIONS[preset]}
+ </p>
+ </div>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
+ onClick={(e) => handleResetPreset(preset, e)}
+ title={`Reset ${PRESET_LABELS[preset]} preset`}
+ >
+ <RotateCcw className="h-3 w-3" />
+ </Button>
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index d988e7fb..1654624c 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -538,6 +538,7 @@ export function PreviewPanel() {
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
});
// Blit offscreen to visible canvas
diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx
index dc6b6d74..f7d5dbda 100644
--- a/apps/web/src/components/editor/properties-panel/property-item.tsx
+++ b/apps/web/src/components/editor/properties-panel/property-item.tsx
@@ -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")} />
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 (
- <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={() =>
- 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={() =>
+ <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, {
- textDecoration:
- element.textDecoration === "underline"
- ? "none"
- : "underline",
+ content: e.target.value,
})
}
- 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
+ />
+ <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">
- <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
+ />
+ </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>
- </div>
+ />
+ </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>
+ ),
+ }))}
+ />
);
}