summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/export-button.tsx293
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx52
-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.tsx47
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx70
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx141
-rw-r--r--apps/web/src/components/editor/panel-base-view.tsx13
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx137
-rw-r--r--apps/web/src/components/editor/scenes-view.tsx198
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx479
-rw-r--r--apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx126
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx190
-rw-r--r--apps/web/src/components/editor/timeline/timeline-marker.tsx67
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx397
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx15
-rw-r--r--apps/web/src/components/footer.tsx17
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx4
-rw-r--r--apps/web/src/components/providers/global-prefetcher.ts78
-rw-r--r--apps/web/src/components/providers/migrators/scenes-migrator.tsx177
-rw-r--r--apps/web/src/components/ui/button.tsx4
-rw-r--r--apps/web/src/components/ui/dialog.tsx4
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx4
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx4
-rw-r--r--apps/web/src/components/ui/popover.tsx2
-rw-r--r--apps/web/src/components/ui/sheet.tsx12
-rw-r--r--apps/web/src/components/ui/split-button.tsx90
26 files changed, 1727 insertions, 896 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index c9738e81..246e8b6f 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -16,7 +16,7 @@ import {
DEFAULT_EXPORT_OPTIONS,
} from "@/lib/export";
import { useProjectStore } from "@/stores/project-store";
-import { Download, X } from "lucide-react";
+import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
@@ -132,137 +132,186 @@ function ExportPopover({
};
return (
- <PopoverContent className="w-80 mr-4 flex flex-col gap-3">
+ <PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
<>
- <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>
+ {exportResult && !exportResult.success ? (
+ <ExportError
+ error={exportResult.error || "Unknown error occurred"}
+ onRetry={handleExport}
+ />
+ ) : (
+ <>
+ <div className="flex items-center justify-between">
+ <h3 className=" font-medium">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ <Button variant="text" size="icon" onClick={handleClose}>
+ <X className="!size-5 text-foreground/85" />
+ </Button>
+ </div>
- <div className="flex flex-col gap-4">
- {!isExporting && (
- <>
- <div className="flex flex-col gap-3">
- <PropertyGroup
- title="Format"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={format}
- onValueChange={(value) => setFormat(value as ExportFormat)}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="mp4" id="mp4" />
- <Label htmlFor="mp4">
- MP4 (H.264) - Better compatibility
- </Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="webm" id="webm" />
- <Label htmlFor="webm">
- WebM (VP9) - Smaller file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col gap-3">
+ <PropertyGroup
+ title="Format"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={format}
+ onValueChange={(value) =>
+ 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>
+ <PropertyGroup
+ title="Quality"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) =>
+ setQuality(value as ExportQuality)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
- <PropertyGroup
- title="Audio"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) => setIncludeAudio(!!checked)}
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
+ <PropertyGroup
+ title="Audio"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) =>
+ setIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </PropertyGroup>
</div>
- </PropertyGroup>
- </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 onClick={handleExport} className="w-full gap-2">
+ <Download className="w-4 h-4" />
+ Export
+ </Button>
+ </>
+ )}
- <Button
- variant="outline"
- className="rounded-md w-full"
- onClick={() => {}}
- >
- Cancel
- </Button>
- </div>
- )}
+ {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>
- {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>
+ <Button
+ variant="outline"
+ className="rounded-md w-full"
+ onClick={() => {}}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
</div>
- )}
- </div>
+ </>
+ )}
</>
</PopoverContent>
);
}
+
+function ExportError({
+ error,
+ onRetry,
+}: {
+ error: string;
+ onRetry: () => void;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
+
+ return (
+ <div className="space-y-4">
+ <div className="flex flex-col gap-1.5">
+ <p className="text-sm font-medium text-red-400">Export failed</p>
+ <p className="text-xs text-muted-foreground">
+ {error}
+ </p>
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1 text-xs h-8"
+ onClick={handleCopy}
+ >
+ {copied ? <Check className="text-green-500" /> : <Copy />}
+ Copy
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1 text-xs h-8"
+ onClick={onRetry}
+ >
+ <RotateCcw />
+ Retry
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx
index 56437508..e2bfdff7 100644
--- a/apps/web/src/components/editor/media-panel/tabbar.tsx
+++ b/apps/web/src/components/editor/media-panel/tabbar.tsx
@@ -7,13 +7,45 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { useEffect, useRef, useState } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+
+ const checkScrollPosition = () => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ setShowTopFade(scrollTop > 0);
+ setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
+ };
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, []);
return (
- <div className="flex">
- <div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4">
+ <div className="flex relative">
+ <div
+ ref={scrollRef}
+ className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
+ >
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
@@ -46,6 +78,22 @@ export function TabBar() {
);
})}
</div>
+
+ <FadeOverlay direction="top" show={showTopFade} />
+ <FadeOverlay direction="bottom" show={showBottomFade} />
</div>
);
}
+
+function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
+ return (
+ <div
+ className={cn(
+ "absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
+ direction === "top" && show
+ ? "top-0 bg-gradient-to-b from-panel to-transparent"
+ : "bottom-0 bg-gradient-to-t from-panel to-transparent"
+ )}
+ />
+ );
+} \ No newline at end of file
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 59403a8d..fbe5b418 100644
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ b/apps/web/src/components/editor/media-panel/views/captions.tsx
@@ -188,7 +188,7 @@ export function Captions() {
};
return (
- <BaseView ref={containerRef} className="flex flex-col justify-between">
+ <BaseView ref={containerRef} className="flex flex-col justify-between h-full">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
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 df1ec54f..be54a9c4 100644
--- a/apps/web/src/components/editor/media-panel/views/settings.tsx
+++ b/apps/web/src/components/editor/media-panel/views/settings.tsx
@@ -16,15 +16,18 @@ import {
} from "../../properties-panel/property-item";
import { FPS_PRESETS } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
+import type { BlurIntensity } from "@/types/project";
import { useEditorStore } from "@/stores/editor-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import Image from "next/image";
import { cn } from "@/lib/utils";
import { colors } from "@/data/colors/solid";
import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { PipetteIcon } from "lucide-react";
+import { PipetteIcon, PlusIcon } from "lucide-react";
import { useMemo, memo, useCallback } from "react";
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
export function SettingsView() {
return <ProjectSettingsTabs />;
@@ -38,14 +41,40 @@ function ProjectSettingsTabs() {
{
value: "project-info",
label: "Project info",
- content: <ProjectInfoView />,
+ content: (
+ <div className="p-5">
+ <ProjectInfoView />
+ </div>
+ ),
},
{
value: "background",
label: "Background",
- content: <BackgroundView />,
+ content: (
+ <div className="flex flex-col justify-between h-full">
+ <div className="flex-1 p-5">
+ <BackgroundView />
+ </div>
+ <div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg">
+ <Separator />
+ <Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs">
+ Custom background
+ <PlusIcon />
+ </Button>
+ </div>
+
+ {/* Another UI, looks so beautiful i don't wanna remove it */}
+ {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
+ <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
+ <span className="text-sm">Custom</span>
+ <PlusIcon className="" />
+ </Button>
+ </div> */}
+ </div>
+ ),
},
]}
+ className="flex flex-col justify-between h-full p-0"
/>
);
}
@@ -177,9 +206,9 @@ const BackgroundPreviews = memo(
}) => {
return useMemo(
() =>
- backgrounds.map((bg) => (
+ backgrounds.map((bg, index) => (
<div
- key={bg}
+ key={`${index}-${bg}`}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary",
isColorBackground &&
@@ -215,7 +244,7 @@ BackgroundPreviews.displayName = "BackgroundPreviews";
function BackgroundView() {
const { activeProject, updateBackgroundType } = useProjectStore();
- const blurLevels = useMemo(
+ const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>(
() => [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
@@ -225,7 +254,7 @@ function BackgroundView() {
);
const handleBlurSelect = useCallback(
- async (blurIntensity: number) => {
+ async (blurIntensity: BlurIntensity) => {
await updateBackgroundType("blur", { blurIntensity });
},
[updateBackgroundType]
@@ -257,7 +286,7 @@ function BackgroundView() {
);
return (
- <div className="flex flex-col gap-4">
+ <div className="flex flex-col gap-4 h-full">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
</PropertyGroup>
@@ -277,7 +306,7 @@ function BackgroundView() {
</div>
</PropertyGroup>
- <PropertyGroup title="Pattern Craft" defaultExpanded={false}>
+ <PropertyGroup title="Pattern craft" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">
<BackgroundPreviews
backgrounds={patternCraftGradients}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
index 303eb858..700eaea7 100644
--- a/apps/web/src/components/editor/media-panel/views/sounds.tsx
+++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx
@@ -82,6 +82,14 @@ function SoundEffectsView() {
toggleSavedSound,
showCommercialOnly,
toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
} = useSoundsStore();
const {
results: searchResults,
@@ -106,6 +114,55 @@ function SoundEffectsView() {
useEffect(() => {
loadSavedSounds();
+ if (!hasLoaded) {
+ let ignore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!ignore) {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads"
+ );
+
+ if (!ignore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects(data.results);
+ setHasLoaded(true);
+
+ setCurrentPage(1);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ }
+ } catch (error) {
+ if (!ignore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError(
+ error instanceof Error ? error.message : "Failed to load sounds"
+ );
+ }
+ } finally {
+ if (!ignore) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }
+
if (scrollAreaRef.current && scrollPosition > 0) {
const timeoutId = setTimeout(() => {
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
@@ -113,14 +170,23 @@ function SoundEffectsView() {
return () => clearTimeout(timeoutId);
}
- }, []);
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
setScrollPosition(scrollTop);
handleScroll(event);
};
-
+
const displayedSounds = useMemo(() => {
const sounds = searchQuery ? searchResults : topSoundEffects;
return sounds;
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
index 41afe285..f67450cc 100644
--- a/apps/web/src/components/editor/media-panel/views/stickers.tsx
+++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx
@@ -20,8 +20,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
-import { Separator } from "@/components/ui/separator";
+import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
import {
Tooltip,
TooltipContent,
@@ -48,63 +47,41 @@ export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore();
return (
- <div className="h-full flex flex-col">
- <Tabs
- value={selectedCategory}
- onValueChange={(v) => {
- if (["all", "general", "brands", "emoji"].includes(v)) {
- setSelectedCategory(v as StickerCategory);
- }
- }}
- className="flex flex-col h-full"
- >
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- <TabsTrigger value="all" className="gap-1">
- <Grid3X3 className="h-3 w-3" />
- All
- </TabsTrigger>
- <TabsTrigger value="general" className="gap-1">
- <Sparkles className="h-3 w-3" />
- Icons
- </TabsTrigger>
- <TabsTrigger value="brands" className="gap-1">
- <Hash className="h-3 w-3" />
- Brands
- </TabsTrigger>
- <TabsTrigger value="emoji" className="gap-1">
- <Smile className="h-3 w-3" />
- Emoji
- </TabsTrigger>
- </TabsList>
- </div>
- <Separator className="my-4" />
- <TabsContent
- value="all"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="all" />
- </TabsContent>
- <TabsContent
- value="general"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="general" />
- </TabsContent>
- <TabsContent
- value="brands"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="brands" />
- </TabsContent>
- <TabsContent
- value="emoji"
- className="px-3 pt-0 mt-0 flex-1 flex flex-col min-h-0"
- >
- <StickersContentView category="emoji" />
- </TabsContent>
- </Tabs>
- </div>
+ <BaseView
+ value={selectedCategory}
+ onValueChange={(v) => {
+ if (["all", "general", "brands", "emoji"].includes(v)) {
+ setSelectedCategory(v as StickerCategory);
+ }
+ }}
+ tabs={[
+ {
+ value: "all",
+ label: "All",
+ icon: <Grid3X3 className="h-3 w-3" />,
+ content: <StickersContentView category="all" />,
+ },
+ {
+ value: "general",
+ label: "Icons",
+ icon: <Sparkles className="h-3 w-3" />,
+ content: <StickersContentView category="general" />,
+ },
+ {
+ value: "brands",
+ label: "Brands",
+ icon: <Hash className="h-3 w-3" />,
+ content: <StickersContentView category="brands" />,
+ },
+ {
+ value: "emoji",
+ label: "Emoji",
+ icon: <Smile className="h-3 w-3" />,
+ content: <StickersContentView category="emoji" />,
+ },
+ ]}
+ className="flex flex-col h-full p-0 overflow-hidden"
+ />
);
}
@@ -204,12 +181,12 @@ function StickersContentView({ category }: { category: StickerCategory }) {
setSelectedCollection,
loadCollections,
searchStickers,
- downloadSticker,
+ addStickerToTimeline,
clearRecentStickers,
setSelectedCategory,
+ addingSticker,
} = useStickersStore();
- const [addingSticker, setAddingSticker] = useState<string | null>(null);
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [collectionsToShow, setCollectionsToShow] = useState(20);
const [showCollectionItems, setShowCollectionItems] = useState(false);
@@ -273,48 +250,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [localSearchQuery]);
const handleAddSticker = async (iconName: string) => {
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setAddingSticker(iconName);
-
try {
- const file = await downloadSticker(iconName);
-
- if (!file) {
- throw new Error("Failed to download sticker");
- }
-
- const mediaItem: Omit<MediaFile, "id"> = {
- name: iconName.replace(":", "-"),
- type: "image",
- file,
- url: URL.createObjectURL(file),
- width: 200,
- height: 200,
- duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- ephemeral: false,
- };
-
- await addMediaFile(activeProject.id, mediaItem);
-
- const added = useMediaStore
- .getState()
- .mediaFiles.find(
- (m) => m.url === mediaItem.url && m.name === mediaItem.name
- );
- if (!added) throw new Error("Sticker not in media store");
-
- addElementAtTime(added, currentTime);
-
- toast.success(`Added "${iconName}" to timeline`);
+ await addStickerToTimeline(iconName);
} catch (error) {
console.error("Failed to add sticker:", error);
toast.error("Failed to add sticker to timeline");
- } finally {
- setAddingSticker(null);
}
};
@@ -363,7 +303,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}, [isInCollection]);
return (
- <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="flex flex-col gap-5 mt-1 h-full p-4">
<div className="space-y-3">
<InputWithBack
isExpanded={isInCollection}
@@ -383,6 +323,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
}
value={localSearchQuery}
onChange={setLocalSearchQuery}
+ disableAnimation={true}
/>
</div>
diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
index ec01028c..6e92c616 100644
--- a/apps/web/src/components/editor/panel-base-view.tsx
+++ b/apps/web/src/components/editor/panel-base-view.tsx
@@ -1,6 +1,7 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
interface PanelBaseViewProps {
children?: React.ReactNode;
@@ -10,6 +11,7 @@ interface PanelBaseViewProps {
tabs?: {
value: string;
label: string;
+ icon?: React.ReactNode;
content: React.ReactNode;
}[];
className?: string;
@@ -25,7 +27,7 @@ function ViewContent({
}) {
return (
<ScrollArea className="flex-1">
- <div className={`p-5 h-full ${className}`}>{children}</div>
+ <div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
}
@@ -40,7 +42,7 @@ export function PanelBaseView({
ref,
}: PanelBaseViewProps) {
return (
- <div className={`h-full flex flex-col ${className}`} ref={ref}>
+ <div className={cn("h-full flex flex-col", className)} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
@@ -55,6 +57,11 @@ export function PanelBaseView({
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
+ {tab.icon ? (
+ <span className="inline-flex items-center mr-1">
+ {tab.icon}
+ </span>
+ ) : null}
{tab.label}
</TabsTrigger>
))}
@@ -68,7 +75,7 @@ export function PanelBaseView({
value={tab.value}
className="mt-0 flex-1 flex flex-col min-h-0"
>
- <ViewContent>{tab.content}</ViewContent>
+ <ViewContent className={className}>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 1654624c..36f6a2ba 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -13,6 +13,8 @@ import { renderTimelineFrame } from "@/lib/timeline-renderer";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { useFrameCache } from "@/hooks/use-frame-cache";
+import { useSceneStore } from "@/stores/scene-store";
import {
DEFAULT_CANVAS_SIZE,
DEFAULT_FPS,
@@ -42,8 +44,11 @@ export function PreviewPanel() {
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore();
+ const { currentScene } = useSceneStore();
const previewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
+ useFrameCache();
const lastFrameTimeRef = useRef(0);
const renderSeqRef = useRef(0);
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
@@ -216,6 +221,16 @@ export function PreviewPanel() {
};
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
+ // Clear the frame cache when background settings change since they affect rendering
+ useEffect(() => {
+ invalidateCache();
+ }, [
+ mediaFiles,
+ activeProject?.backgroundColor,
+ activeProject?.backgroundType,
+ invalidateCache,
+ ]);
+
const handleTextMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
element: any,
@@ -246,6 +261,7 @@ export function PreviewPanel() {
}, []);
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
+ const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
@@ -449,7 +465,7 @@ export function PreviewPanel() {
};
}, [isPlaying, volume, muted, mediaFiles]);
- // Canvas: draw current frame for visible elements using offscreen compositing
+ // Canvas: draw current frame with caching
useEffect(() => {
const draw = async () => {
const canvas = canvasRef.current;
@@ -475,10 +491,94 @@ export function PreviewPanel() {
lastFrameTimeRef.current = currentTime;
}
- // Invalidate older async renders when user scrubs rapidly
- const mySeq = (renderSeqRef.current += 1);
+ const cachedFrame = getCachedFrame(
+ currentTime,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ );
+ if (cachedFrame) {
+ mainCtx.putImageData(cachedFrame, 0, 0);
+
+ // Pre-render nearby frames in background
+ if (!isPlaying) {
+ // Only during scrubbing to avoid interfering with playback
+ preRenderNearbyFrames(
+ currentTime,
+ tracks,
+ mediaFiles,
+ activeProject,
+ async (time: number) => {
+ const tempCanvas = document.createElement("canvas");
+ tempCanvas.width = displayWidth;
+ tempCanvas.height = displayHeight;
+ const tempCtx = tempCanvas.getContext("2d");
+ if (!tempCtx)
+ throw new Error("Failed to create temp canvas context");
- // Offscreen buffer to avoid flicker (reuse canvas)
+ await renderTimelineFrame({
+ ctx: tempCtx,
+ time,
+ canvasWidth: displayWidth,
+ canvasHeight: displayHeight,
+ tracks,
+ mediaFiles,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? undefined
+ : activeProject?.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
+ });
+
+ return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
+ },
+ currentScene?.id,
+ 3
+ );
+ } else {
+ // Small lookahead while playing
+ preRenderNearbyFrames(
+ currentTime,
+ tracks,
+ mediaFiles,
+ activeProject,
+ async (time: number) => {
+ const tempCanvas = document.createElement("canvas");
+ tempCanvas.width = displayWidth;
+ tempCanvas.height = displayHeight;
+ const tempCtx = tempCanvas.getContext("2d");
+ if (!tempCtx)
+ throw new Error("Failed to create temp canvas context");
+
+ await renderTimelineFrame({
+ ctx: tempCtx,
+ time,
+ canvasWidth: displayWidth,
+ canvasHeight: displayHeight,
+ tracks,
+ mediaFiles,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? undefined
+ : activeProject?.backgroundColor || "#000000",
+ projectCanvasSize: canvasSize,
+ });
+
+ return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
+ },
+ currentScene?.id,
+ 1
+ );
+ }
+ return;
+ }
+
+ // Cache miss - render from scratch
if (!offscreenCanvasRef.current) {
const hasOffscreen =
typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
@@ -534,13 +634,30 @@ export function PreviewPanel() {
canvasHeight: displayHeight,
tracks,
mediaFiles,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
backgroundColor:
activeProject?.backgroundType === "blur"
- ? "transparent"
+ ? undefined
: activeProject?.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
+ const imageData = (offCtx as CanvasRenderingContext2D).getImageData(
+ 0,
+ 0,
+ displayWidth,
+ displayHeight
+ );
+ cacheFrame(
+ currentTime,
+ imageData,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ );
+
// Blit offscreen to visible canvas
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
if ((offscreenCanvas as HTMLCanvasElement).getContext) {
@@ -564,6 +681,10 @@ export function PreviewPanel() {
canvasSize.height,
activeProject?.backgroundType,
activeProject?.backgroundColor,
+ getCachedFrame,
+ cacheFrame,
+ preRenderNearbyFrames,
+ isPlaying,
]);
// Get media elements for blur background (video/image only)
@@ -593,7 +714,7 @@ export function PreviewPanel() {
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
>
<div className="flex-1" />
- {hasAnyElements ? (
+ {shouldRenderPreview ? (
<div
ref={previewRef}
className="relative overflow-hidden border"
@@ -619,9 +740,7 @@ export function PreviewPanel() {
aria-label="Video preview canvas"
/>
{activeElements.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- No elements at current time
- </div>
+ <></>
) : (
activeElements.map((elementData) => renderElement(elementData))
)}
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx
new file mode 100644
index 00000000..b5432b53
--- /dev/null
+++ b/apps/web/src/components/editor/scenes-view.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { useSceneStore } from "@/stores/scene-store";
+import { Check, ListCheck, Trash2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export function ScenesView({ children }: { children: React.ReactNode }) {
+ const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
+ const [isSelectMode, setIsSelectMode] = useState(false);
+ const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
+
+ const handleSceneSwitch = async (sceneId: string) => {
+ if (isSelectMode) {
+ toggleSceneSelection(sceneId);
+ return;
+ }
+
+ try {
+ await switchToScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to switch scene:", error);
+ }
+ };
+
+ const toggleSceneSelection = (sceneId: string) => {
+ setSelectedScenes((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(sceneId)) {
+ newSet.delete(sceneId);
+ } else {
+ newSet.add(sceneId);
+ }
+ return newSet;
+ });
+ };
+
+ const handleSelectMode = () => {
+ setIsSelectMode(!isSelectMode);
+ setSelectedScenes(new Set());
+ };
+
+ const handleDeleteSelected = async () => {
+ for (const sceneId of selectedScenes) {
+ const scene = scenes.find((s) => s.id === sceneId);
+ if (scene && !scene.isMain) {
+ try {
+ await deleteScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to delete scene:", error);
+ }
+ }
+ }
+ setSelectedScenes(new Set());
+ setIsSelectMode(false);
+ };
+
+ return (
+ <Sheet>
+ <SheetTrigger asChild>{children}</SheetTrigger>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>
+ {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
+ </SheetTitle>
+ <SheetDescription>
+ {isSelectMode
+ ? "Select scenes to delete"
+ : "Switch between scenes in your project"}
+ </SheetDescription>
+ </SheetHeader>
+ <div className="py-4 flex flex-col gap-4">
+ <div className="flex items-center gap-2">
+ <Button
+ className="rounded-md"
+ variant={isSelectMode ? "default" : "outline"}
+ size="sm"
+ onClick={handleSelectMode}
+ >
+ <ListCheck />
+ {isSelectMode ? "Cancel" : "Select"}
+ </Button>
+ {isSelectMode && (
+ <DeleteDialog
+ count={selectedScenes.size}
+ onDelete={handleDeleteSelected}
+ disabled={Array.from(selectedScenes).some(
+ (id) => scenes.find((s) => s.id === id)?.isMain
+ )}
+ >
+ <Button className="rounded-md" variant="destructive" size="sm">
+ <Trash2 />
+ Delete ({selectedScenes.size})
+ </Button>
+ </DeleteDialog>
+ )}
+ </div>
+ {scenes.length === 0 ? (
+ <div className="text-sm text-muted-foreground">
+ No scenes available
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {scenes.map((scene) => (
+ <Button
+ key={scene.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-between font-normal",
+ currentScene?.id === scene.id &&
+ !isSelectMode &&
+ "border-primary !text-primary",
+ isSelectMode &&
+ selectedScenes.has(scene.id) &&
+ "bg-accent border-foreground/30"
+ )}
+ onClick={() => handleSceneSwitch(scene.id)}
+ >
+ <span>{scene.name}</span>
+ <div className="flex items-center gap-2">
+ {((isSelectMode && selectedScenes.has(scene.id)) ||
+ (!isSelectMode && currentScene?.id === scene.id)) && (
+ <Check className="h-4 w-4" />
+ )}
+ </div>
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+}
+
+function DeleteDialog({
+ count,
+ onDelete,
+ disabled,
+ children,
+}: {
+ count: number;
+ onDelete: () => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const handleDelete = () => {
+ onDelete();
+ setOpen(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>{children}</DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Scenes</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete {count} scene
+ {count === 1 ? "" : "s"}? This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={disabled}
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index c994d707..4a59fd21 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -57,6 +57,9 @@ import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
+import { TimelineCacheIndicator } from "./timeline-cache-indicator";
+import { TimelineMarker } from "./timeline-marker";
+import { useFrameCache } from "@/hooks/use-frame-cache";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -67,6 +70,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { TimelineToolbar } from "./timeline-toolbar";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -85,6 +89,7 @@ export function Timeline() {
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
+ const { getRenderStatus } = useFrameCache();
const [isDragOver, setIsDragOver] = useState(false);
const { addElementToNewTrack } = useTimelineStore();
const dragCounterRef = useRef(0);
@@ -658,7 +663,21 @@ export function Timeline() {
onClick={handleTimelineContentClick}
data-ruler-area
>
- <ScrollArea className="w-full" ref={rulerScrollRef}>
+ <ScrollArea
+ className="w-full"
+ ref={rulerScrollRef}
+ onScroll={(e) => {
+ if (isUpdatingRef.current) return;
+ isUpdatingRef.current = true;
+ const tracksViewport = tracksScrollRef.current;
+ if (tracksViewport) {
+ tracksViewport.scrollLeft = (
+ e.currentTarget as HTMLDivElement
+ ).scrollLeft;
+ }
+ isUpdatingRef.current = false;
+ }}
+ >
<div
ref={rulerRef}
className="relative h-10 select-none cursor-default"
@@ -667,6 +686,14 @@ export function Timeline() {
}}
onMouseDown={handleRulerMouseDown}
>
+ <TimelineCacheIndicator
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ mediaFiles={mediaFiles}
+ activeProject={activeProject}
+ getRenderStatus={getRenderStatus}
+ />
{/* Time markers */}
{(() => {
// Calculate appropriate time interval based on zoom level
@@ -693,55 +720,13 @@ export function Timeline() {
time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
return (
- <div
+ <TimelineMarker
key={i}
- className={`absolute top-0 h-4 ${
- isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- }`}
- style={{
- left: `${
- time *
- TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
- zoomLevel
- }px`,
- }}
- >
- <span
- className={`absolute top-1 left-1 text-[0.6rem] ${
- isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- }`}
- >
- {(() => {
- const formatTime = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes
- .toString()
- .padStart(2, "0")}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (minutes > 0) {
- return `${minutes}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- }
- return `${secs.toFixed(1)}s`;
- };
- return formatTime(time);
- })()}
- </span>
- </div>
+ time={time}
+ zoomLevel={zoomLevel}
+ interval={interval}
+ isMainMarker={isMainMarker}
+ />
);
}).filter(Boolean);
})()}
@@ -836,7 +821,21 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
+ <ScrollArea
+ className="w-full h-full"
+ ref={tracksScrollRef}
+ onScroll={(e) => {
+ if (isUpdatingRef.current) return;
+ isUpdatingRef.current = true;
+ const rulerViewport = rulerScrollRef.current;
+ if (rulerViewport) {
+ rulerViewport.scrollLeft = (
+ e.currentTarget as HTMLDivElement
+ ).scrollLeft;
+ }
+ isUpdatingRef.current = false;
+ }}
+ >
<div
className="relative flex-1"
style={{
@@ -924,383 +923,3 @@ function TrackIcon({ track }: { track: TimelineTrack }) {
</>
);
}
-
-function TimelineToolbar({
- zoomLevel,
- setZoomLevel,
-}: {
- zoomLevel: number;
- setZoomLevel: (zoom: number) => void;
-}) {
- const {
- tracks,
- addTrack,
- addElementToTrack,
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
- selectedElements,
- clearSelectedElements,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- snappingEnabled,
- toggleSnapping,
- rippleEditingEnabled,
- toggleRippleEditing,
- } = useTimelineStore();
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
-
- const handleSplitSelected = () => {
- if (selectedElements.length === 0) return;
- let splitCount = 0;
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (element && track) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newElementId = splitElement(trackId, elementId, currentTime);
- if (newElementId) splitCount++;
- }
- }
- });
- if (splitCount === 0) {
- toast.error("Playhead must be within selected elements to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) return;
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
-
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((el) => el.id === elementId);
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
- const { id, ...elementWithoutId } = element;
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
- }
- });
- clearSelectedElements();
- };
-
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
-
- const handleSplitAndKeepLeft = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepLeft(trackId, elementId, currentTime);
- };
-
- const handleSplitAndKeepRight = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepRight(trackId, elementId, currentTime);
- };
-
- const handleSeparateAudio = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
- separateAudio(trackId, elementId);
- };
-
- const handleDeleteSelected = () => {
- if (selectedElements.length === 0) return;
- selectedElements.forEach(({ trackId, elementId }) => {
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- removeElementFromTrack(trackId, elementId);
- }
- });
- clearSelectedElements();
- };
-
- const handleZoomIn = () => {
- setZoomLevel(Math.min(4, zoomLevel + 0.25));
- };
-
- const handleZoomOut = () => {
- setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
- };
-
- const handleZoomSliderChange = (values: number[]) => {
- setZoomLevel(values[0]);
- };
-
- const handleToggleBookmark = async () => {
- await toggleBookmark(currentTime);
- };
-
- const currentBookmarked = isBookmarked(currentTime);
- return (
- <div className="border-b flex items-center justify-between px-2 py-1">
- <div className="flex items-center gap-1 w-full">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => seek(0)}
- className="mr-2"
- >
- <SkipBack className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Return to Start (Home / Enter)</TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div className="flex flex-row items-center justify-center px-2">
- <EditableTimecode
- time={currentTime}
- duration={duration}
- format="HH:MM:SS:FF"
- fps={activeProject?.fps ?? DEFAULT_FPS}
- onTimeChange={seek}
- className="text-center"
- />
- <div className="text-xs text-muted-foreground font-mono px-2">
- /
- </div>
- <div className="text-xs text-muted-foreground font-mono text-center">
- {formatTimeCode(duration, "HH:MM:SS:FF")}
- </div>
- </div>
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSplitSelected}>
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSeparateAudio}>
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleFreezeSelected}>
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleDeleteSelected}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleToggleBookmark}>
- <Bookmark
- className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleSnapping}>
- {snappingEnabled ? (
- <Magnet className="h-4 w-4 text-primary" />
- ) : (
- <Magnet className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>Auto snapping</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleRippleEditing}>
- <Link
- className={`h-4 w-4 ${
- rippleEditingEnabled ? "text-primary" : ""
- }`}
- />
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {rippleEditingEnabled
- ? "Disable Ripple Editing"
- : "Enable Ripple Editing"}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
-
- <div className="h-6 w-px bg-border mx-1" />
- <div className="flex items-center gap-1">
- <Button variant="text" size="icon" onClick={handleZoomOut}>
- <ZoomOut className="h-4 w-4" />
- </Button>
- <Slider
- className="w-24"
- value={[zoomLevel]}
- onValueChange={handleZoomSliderChange}
- min={0.25}
- max={4}
- step={0.25}
- />
- <Button variant="text" size="icon" onClick={handleZoomIn}>
- <ZoomIn className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
new file mode 100644
index 00000000..7def2985
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { TimelineTrack } from "@/types/timeline";
+import { MediaFile } from "@/types/media";
+import { TProject } from "@/types/project";
+import { useSceneStore } from "@/stores/scene-store";
+
+interface CacheSegment {
+ startTime: number;
+ endTime: number;
+ cached: boolean;
+}
+
+interface TimelineCacheIndicatorProps {
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ mediaFiles: MediaFile[];
+ activeProject: TProject | null;
+ getRenderStatus: (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ sceneId?: string
+ ) => "cached" | "not-cached";
+}
+
+export function TimelineCacheIndicator({
+ duration,
+ zoomLevel,
+ tracks,
+ mediaFiles,
+ activeProject,
+ getRenderStatus,
+}: TimelineCacheIndicatorProps) {
+ const { currentScene } = useSceneStore();
+
+ // Calculate cache segments by sampling the timeline
+ const calculateCacheSegments = (): CacheSegment[] => {
+ const segments: CacheSegment[] = [];
+ const sampleRate = 10; // Sample every 0.1 seconds
+ const totalSamples = Math.ceil(duration * sampleRate);
+
+ if (totalSamples === 0) {
+ return [{ startTime: 0, endTime: duration, cached: false }];
+ }
+
+ let currentSegment: CacheSegment | null = null;
+
+ for (let i = 0; i <= totalSamples; i++) {
+ const time = i / sampleRate;
+ const cached =
+ getRenderStatus(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ ) === "cached";
+
+ if (!currentSegment) {
+ // Start first segment
+ currentSegment = {
+ startTime: time,
+ endTime: time,
+ cached,
+ };
+ } else if (currentSegment.cached === cached) {
+ // Extend current segment
+ currentSegment.endTime = time;
+ } else {
+ // Finish current segment and start new one
+ segments.push(currentSegment);
+ currentSegment = {
+ startTime: time,
+ endTime: time,
+ cached,
+ };
+ }
+ }
+
+ // Add the last segment
+ if (currentSegment) {
+ currentSegment.endTime = duration;
+ segments.push(currentSegment);
+ }
+
+ return segments;
+ };
+
+ const cacheSegments = calculateCacheSegments();
+
+ return (
+ <div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none">
+ {cacheSegments.map((segment, index) => {
+ const startX =
+ segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const endX =
+ segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const width = endX - startX;
+
+ return (
+ <div
+ key={index}
+ className={cn(
+ "absolute top-0 h-px",
+ segment.cached ? "bg-primary" : "bg-transparent"
+ )}
+ style={{
+ left: `${startX}px`,
+ width: `${width}px`,
+ }}
+ title={
+ segment.cached
+ ? "Cached (fast playback)"
+ : "Not cached (will render)"
+ }
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index 677b9c61..ca47d3ca 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { toast } from "sonner";
-import { TimelineElementProps } from "@/types/timeline";
+import { TimelineElementProps, MediaElement } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
@@ -42,16 +42,17 @@ export function TimelineElement({
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const {
- removeElementFromTrack,
- removeElementFromTrackWithRipple,
dragState,
- splitElement,
- addElementToTrack,
- replaceElementMedia,
- rippleEditingEnabled,
- toggleElementHidden,
- toggleElementMuted,
copySelected,
+ selectedElements,
+ deleteSelected,
+ splitSelected,
+ toggleSelectedHidden,
+ toggleSelectedMuted,
+ duplicateElement,
+ revealElementInMedia,
+ replaceElementWithFile,
+ getContextMenuState,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -68,7 +69,12 @@ export function TimelineElement({
zoomLevel,
});
- const { requestRevealMedia } = useMediaPanelStore.getState();
+ const {
+ isMultipleSelected,
+ isCurrentElementSelected,
+ hasAudioElements,
+ canSplitSelected,
+ } = getContextMenuState(track.id, element.id);
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
@@ -77,44 +83,26 @@ export function TimelineElement({
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
- // Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
- // Element should always be positioned at startTime - trimStart only affects content, not position
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleElementSplitContext = (e: React.MouseEvent) => {
e.stopPropagation();
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
+ splitSelected(
+ currentTime,
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
};
const handleElementDuplicateContext = (e: React.MouseEvent) => {
e.stopPropagation();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
+ duplicateElement(track.id, element.id);
};
const handleElementCopyContext = (e: React.MouseEvent) => {
@@ -124,49 +112,38 @@ export function TimelineElement({
const handleElementDeleteContext = (e: React.MouseEvent) => {
e.stopPropagation();
- if (rippleEditingEnabled) {
- removeElementFromTrackWithRipple(track.id, element.id);
- } else {
- removeElementFromTrack(track.id, element.id);
- }
+ deleteSelected(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
};
const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
+
if (hasAudio && element.type === "media") {
- toggleElementMuted(track.id, element.id);
- return;
+ toggleSelectedMuted(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
+ } else {
+ toggleSelectedHidden(
+ isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
+ isMultipleSelected && isCurrentElementSelected ? undefined : element.id
+ );
}
- toggleElementHidden(track.id, element.id);
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
- if (element.type !== "media") {
- toast.error("Replace is only available for media clips");
- return;
- }
- // Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
- if (!file) return;
-
- try {
- const result = await replaceElementMedia(track.id, element.id, file);
- if (result.success) {
- toast.success("Clip replaced successfully");
- } else {
- toast.error(result.error || "Failed to replace clip");
- }
- } catch (error) {
- console.error("Unexpected error replacing clip:", error);
- toast.error(
- `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
- );
+ if (file) {
+ await replaceElementWithFile(track.id, element.id, file);
}
};
input.click();
@@ -174,12 +151,7 @@ export function TimelineElement({
const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation();
- if (element.type !== "media") {
- toast.error("Reveal is only available for media clips");
- return;
- }
-
- requestRevealMedia(element.mediaId);
+ revealElementInMedia(element.id);
};
const renderElementContent = () => {
@@ -191,7 +163,6 @@ export function TimelineElement({
);
}
- // Render media element ->
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) {
return (
@@ -201,20 +172,15 @@ export function TimelineElement({
);
}
- const TILE_ASPECT_RATIO = 16 / 9;
-
if (
mediaItem.type === "image" ||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
) {
- // Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight;
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
+ const tileWidth = trackHeight * (16 / 9);
const imageUrl =
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
- const isImage = mediaItem.type === "image";
return (
<div className="w-full h-full flex items-center justify-center">
@@ -228,18 +194,17 @@ export function TimelineElement({
style={{
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
- aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
+ aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
/>
</div>
</div>
);
}
- // Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
@@ -267,7 +232,7 @@ export function TimelineElement({
}
};
- const isMuted = element.type === "media" ? element.muted === true : false;
+ const isMuted = element.type === "media" && element.muted;
return (
<ContextMenu>
@@ -332,16 +297,33 @@ export function TimelineElement({
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-200">
- <ContextMenuItem onClick={handleElementSplitContext}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at playhead
- </ContextMenuItem>
+ {(!isMultipleSelected ||
+ (isMultipleSelected &&
+ isCurrentElementSelected &&
+ canSplitSelected)) && (
+ <ContextMenuItem onClick={handleElementSplitContext}>
+ <Scissors className="h-4 w-4 mr-2" />
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Split ${selectedElements.length} elements at playhead`
+ : "Split at playhead"}
+ </ContextMenuItem>
+ )}
+
<ContextMenuItem onClick={handleElementCopyContext}>
<Copy className="h-4 w-4 mr-2" />
- Copy element
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Copy ${selectedElements.length} elements`
+ : "Copy element"}
</ContextMenuItem>
+
<ContextMenuItem onClick={handleToggleElementContext}>
- {hasAudio ? (
+ {isMultipleSelected && isCurrentElementSelected ? (
+ hasAudioElements ? (
+ <VolumeX className="h-4 w-4 mr-2" />
+ ) : (
+ <EyeOff className="h-4 w-4 mr-2" />
+ )
+ ) : hasAudio ? (
isMuted ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
@@ -353,21 +335,29 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
- {hasAudio
- ? isMuted
- ? "Unmute"
- : "Mute"
- : element.hidden
- ? "Show"
- : "Hide"}{" "}
- {element.type === "text" ? "text" : "clip"}
+ {isMultipleSelected && isCurrentElementSelected
+ ? hasAudioElements
+ ? `Toggle mute ${selectedElements.length} elements`
+ : `Toggle visibility ${selectedElements.length} elements`
+ : hasAudio
+ ? isMuted
+ ? "Unmute"
+ : "Mute"
+ : element.hidden
+ ? "Show"
+ : "Hide"}{" "}
+ {!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
</span>
</ContextMenuItem>
- <ContextMenuItem onClick={handleElementDuplicateContext}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- {element.type === "media" && (
+
+ {!isMultipleSelected && (
+ <ContextMenuItem onClick={handleElementDuplicateContext}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ )}
+
+ {!isMultipleSelected && element.type === "media" && (
<>
<ContextMenuItem onClick={handleRevealInMedia}>
<Search className="h-4 w-4 mr-2" />
@@ -379,13 +369,17 @@ export function TimelineElement({
</ContextMenuItem>
</>
)}
+
<ContextMenuSeparator />
+
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
- Delete {element.type === "text" ? "text" : "clip"}
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedElements.length} elements`
+ : `Delete ${element.type === "text" ? "text" : "clip"}`}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
new file mode 100644
index 00000000..1351a457
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+interface TimelineMarkerProps {
+ time: number;
+ zoomLevel: number;
+ interval: number;
+ isMainMarker: boolean;
+}
+
+export function TimelineMarker({
+ time,
+ zoomLevel,
+ interval,
+ isMainMarker,
+}: TimelineMarkerProps) {
+ return (
+ <div
+ className={cn(
+ "absolute top-0 h-4",
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ )}
+ style={{
+ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ >
+ <span
+ className={cn(
+ "absolute top-1 left-1 text-[0.6rem]",
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ )}
+ >
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes
+ .toString()
+ .padStart(2, "0")}:${Math.floor(secs)
+ .toString()
+ .padStart(2, "0")}`;
+ }
+ if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs)
+ .toString()
+ .padStart(2, "0")}`;
+ }
+ if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ }
+ return `${secs.toFixed(1)}s`;
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
new file mode 100644
index 00000000..80c6ce08
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -0,0 +1,397 @@
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useSceneStore } from "@/stores/scene-store";
+import { toast } from "sonner";
+import {
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import {
+ Pause,
+ Play,
+ SkipBack,
+ Bookmark,
+ Magnet,
+ Link,
+ ZoomOut,
+ ZoomIn,
+ Copy,
+ Trash2,
+ Snowflake,
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ SplitSquareHorizontal,
+ Scissors,
+ LayersIcon,
+} from "lucide-react";
+import {
+ SplitButton,
+ SplitButtonLeft,
+ SplitButtonRight,
+ SplitButtonSeparator,
+} from "@/components/ui/split-button";
+import { Slider } from "@/components/ui/slider";
+import { DEFAULT_FPS } from "@/stores/project-store";
+import { formatTimeCode } from "@/lib/time";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { ScenesView } from "../scenes-view";
+
+export function TimelineToolbar({
+ zoomLevel,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ setZoomLevel: (zoom: number) => void;
+}) {
+ const {
+ tracks,
+ addTrack,
+ addElementToTrack,
+ selectedElements,
+ clearSelectedElements,
+ deleteSelected,
+ splitSelected,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ snappingEnabled,
+ toggleSnapping,
+ rippleEditingEnabled,
+ toggleRippleEditing,
+ } = useTimelineStore();
+ const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
+ const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
+ const { scenes, currentScene } = useSceneStore();
+
+ const handleSplitSelected = () => {
+ splitSelected(currentTime);
+ };
+
+ const handleDuplicateSelected = () => {
+ if (selectedElements.length === 0) return;
+ const canDuplicate = selectedElements.length === 1;
+ if (!canDuplicate) return;
+
+ selectedElements.forEach(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((el) => el.id === elementId);
+ if (element) {
+ const newStartTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1;
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(trackId, {
+ ...elementWithoutId,
+ startTime: newStartTime,
+ });
+ }
+ });
+ clearSelectedElements();
+ };
+
+ const handleFreezeSelected = () => {
+ toast.info("Freeze frame functionality coming soon!");
+ };
+
+ const handleSplitAndKeepLeft = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
+ splitAndKeepLeft(trackId, elementId, currentTime);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one element");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+ if (!element) return;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within selected element");
+ return;
+ }
+ splitAndKeepRight(trackId, elementId, currentTime);
+ };
+
+ const handleSeparateAudio = () => {
+ if (selectedElements.length !== 1) {
+ toast.error("Select exactly one media element to separate audio");
+ return;
+ }
+ const { trackId, elementId } = selectedElements[0];
+ const track = tracks.find((t) => t.id === trackId);
+ if (!track || track.type !== "media") {
+ toast.error("Select a media element to separate audio");
+ return;
+ }
+ separateAudio(trackId, elementId);
+ };
+
+ const handleDeleteSelected = () => {
+ deleteSelected();
+ };
+
+ const handleZoomIn = () => {
+ setZoomLevel(Math.min(4, zoomLevel + 0.25));
+ };
+
+ const handleZoomOut = () => {
+ setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
+ };
+
+ const handleZoomSliderChange = (values: number[]) => {
+ setZoomLevel(values[0]);
+ };
+
+ const handleToggleBookmark = async () => {
+ await toggleBookmark(currentTime);
+ };
+
+ const currentBookmarked = isBookmarked(currentTime);
+ return (
+ <div className="flex items-center justify-between px-2 py-1 border-b h-10">
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggle}>
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={() => seek(0)}>
+ <SkipBack className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Return to Start (Home / Enter)</TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Time Display */}
+ <div className="flex flex-row items-center justify-center px-2">
+ <EditableTimecode
+ time={currentTime}
+ duration={duration}
+ format="HH:MM:SS:FF"
+ fps={activeProject?.fps ?? DEFAULT_FPS}
+ onTimeChange={seek}
+ className="text-center"
+ />
+ <div className="text-xs text-muted-foreground font-mono px-2">
+ /
+ </div>
+ <div className="text-xs text-muted-foreground font-mono text-center">
+ {formatTimeCode(duration, "HH:MM:SS:FF")}
+ </div>
+ </div>
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("media");
+ addElementToTrack(trackId, {
+ type: "media",
+ mediaId: "test",
+ name: "Test Clip",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSplitSelected}>
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split element (Ctrl+S)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSeparateAudio}>
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleDeleteSelected}>
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete element (Delete)</TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleToggleBookmark}>
+ <Bookmark
+ className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div>
+ <SplitButton className="border border-foreground/10">
+ <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
+ <SplitButtonSeparator />
+ <ScenesView>
+ <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
+ <LayersIcon />
+ </SplitButtonRight>
+ </ScenesView>
+ </SplitButton>
+ </div>
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleSnapping}>
+ {snappingEnabled ? (
+ <Magnet className="h-4 w-4 text-primary" />
+ ) : (
+ <Magnet className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Auto snapping</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleRippleEditing}>
+ <Link
+ className={`h-4 w-4 ${
+ rippleEditingEnabled ? "text-primary" : ""
+ }`}
+ />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {rippleEditingEnabled
+ ? "Disable Ripple Editing"
+ : "Enable Ripple Editing"}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <div className="h-6 w-px bg-border mx-1" />
+ <div className="flex items-center gap-1">
+ <Button variant="text" size="icon" onClick={handleZoomOut}>
+ <ZoomOut className="h-4 w-4" />
+ </Button>
+ <Slider
+ className="w-24"
+ value={[zoomLevel]}
+ onValueChange={handleZoomSliderChange}
+ min={0.25}
+ max={4}
+ step={0.25}
+ />
+ <Button variant="text" size="icon" onClick={handleZoomIn}>
+ <ZoomIn className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 42c78aa4..7cfde931 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -1147,7 +1147,7 @@ export function TimelineTrackContent({
const handleElementSplit = () => {
const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
+ const { splitSelected } = useTimelineStore();
const splitTime = currentTime;
const effectiveStart = element.startTime;
const effectiveEnd =
@@ -1155,14 +1155,7 @@ export function TimelineTrackContent({
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- const secondElementId = splitElement(
- track.id,
- element.id,
- splitTime
- );
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
+ splitSelected(splitTime, track.id, element.id);
} else {
toast.error("Playhead must be within element to split");
}
@@ -1182,8 +1175,8 @@ export function TimelineTrackContent({
};
const handleElementDelete = () => {
- const { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
+ const { deleteSelected } = useTimelineStore.getState();
+ deleteSelected(track.id, element.id);
};
return (
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 0087502e..176452cd 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -2,28 +2,11 @@
import { motion } from "motion/react";
import Link from "next/link";
-import { useEffect, useState } from "react";
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
-import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
- const [star, setStar] = useState<string>();
-
- useEffect(() => {
- const fetchStars = async () => {
- try {
- const data = await getStars();
- setStar(data);
- } catch (err) {
- console.error("Failed to fetch GitHub stars", err);
- }
- };
-
- fetchStars();
- }, []);
-
return (
<motion.footer
className="bg-background border-t"
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
index dd6a500b..689c3ee3 100644
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ b/apps/web/src/components/keyboard-shortcuts-help.tsx
@@ -111,7 +111,7 @@ export function KeyboardShortcutsHelp() {
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
- <DialogHeader className="flex-shrink-0 p-6 pb-4">
+ <DialogHeader className="flex-shrink-0 p-6 pb-0">
<DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5" />
Keyboard Shortcuts
@@ -123,7 +123,7 @@ export function KeyboardShortcutsHelp() {
</DialogHeader>
<div className="overflow-y-auto flex-grow scrollbar-thin">
- <div className="space-y-6 p-6">
+ <div className="space-y-6 p-6 pt-2">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
<h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts
deleted file mode 100644
index 2d7c684c..00000000
--- a/apps/web/src/components/providers/global-prefetcher.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-export function useGlobalPrefetcher() {
- const {
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- } = useSoundsStore();
-
- useEffect(() => {
- if (hasLoaded) return;
-
- let ignore = false;
-
- const prefetchTopSounds = async () => {
- try {
- if (!ignore) {
- setLoading(true);
- setError(null);
- }
-
- const response = await fetch(
- "/api/sounds/search?page_size=50&sort=downloads"
- );
-
- if (!ignore) {
- if (!response.ok) {
- throw new Error(`Failed to fetch: ${response.status}`);
- }
-
- const data = await response.json();
- setTopSoundEffects(data.results);
- setHasLoaded(true);
-
- // Set pagination state for top sounds
- setCurrentPage(1);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- }
- } catch (error) {
- if (!ignore) {
- console.error("Failed to prefetch top sounds:", error);
- setError(
- error instanceof Error ? error.message : "Failed to load sounds"
- );
- }
- } finally {
- if (!ignore) {
- setLoading(false);
- }
- }
- };
-
- const timeoutId = setTimeout(prefetchTopSounds, 100);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- ]);
-}
diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
new file mode 100644
index 00000000..b984a7f4
--- /dev/null
+++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { storageService } from "@/lib/storage/storage-service";
+import { TProject, Scene } from "@/types/project";
+import { generateUUID } from "@/lib/utils";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Progress } from "@/components/ui/progress";
+import { createMainScene } from "@/stores/project-store";
+
+interface MigrationProgress {
+ current: number;
+ total: number;
+ currentProjectName: string;
+}
+
+export function ScenesMigrator({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const [isMigrating, setIsMigrating] = useState(false);
+ const [progress, setProgress] = useState<MigrationProgress>({
+ current: 0,
+ total: 0,
+ currentProjectName: "",
+ });
+
+ const shouldCheckMigration =
+ pathname.startsWith("/editor") || pathname.startsWith("/projects");
+
+ useEffect(() => {
+ if (!shouldCheckMigration) return;
+
+ checkAndMigrateProjects();
+ }, [shouldCheckMigration]);
+
+ const checkAndMigrateProjects = async () => {
+ try {
+ const projects = await storageService.loadAllProjects();
+ const legacyProjects = projects.filter(
+ (project) => !project.scenes || project.scenes.length === 0
+ );
+
+ if (legacyProjects.length === 0) {
+ // No migration needed
+ return;
+ }
+
+ setIsMigrating(true);
+ setProgress({
+ current: 0,
+ total: legacyProjects.length,
+ currentProjectName: "",
+ });
+
+ // Migrate each legacy project
+ for (let i = 0; i < legacyProjects.length; i++) {
+ const project = legacyProjects[i];
+
+ setProgress({
+ current: i,
+ total: legacyProjects.length,
+ currentProjectName: project.name,
+ });
+
+ await migrateLegacyProject(project);
+ }
+
+ setProgress({
+ current: legacyProjects.length,
+ total: legacyProjects.length,
+ currentProjectName: "Complete!",
+ });
+
+ setTimeout(() => {
+ setIsMigrating(false);
+ }, 1000);
+ } catch (error) {
+ console.error("Migration failed:", error);
+ setIsMigrating(false);
+ }
+ };
+
+ const migrateLegacyProject = async (project: TProject) => {
+ try {
+ const mainScene: Scene = {
+ id: generateUUID(),
+ name: "Main Scene",
+ isMain: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ const migratedProject: TProject = {
+ ...project,
+ scenes: [mainScene],
+ currentSceneId: mainScene.id,
+ updatedAt: new Date(),
+ };
+
+ // Load existing timeline data (legacy format)
+ const legacyTimeline = await storageService.loadTimeline({
+ projectId: project.id,
+ });
+
+ await storageService.saveProject({ project: migratedProject });
+
+ // If timeline data, migrate it to the main scene
+ if (legacyTimeline && legacyTimeline.length > 0) {
+ await storageService.saveTimeline({
+ projectId: project.id,
+ tracks: legacyTimeline,
+ sceneId: mainScene.id,
+ });
+ }
+
+ // Clean up legacy timeline storage
+ await storageService.deleteProjectTimeline({ projectId: project.id });
+ } catch (error) {
+ console.error(`Failed to migrate project ${project.name}:`, error);
+ throw error;
+ }
+ };
+
+ if (!shouldCheckMigration) {
+ return children;
+ }
+
+ if (isMigrating) {
+ const progressPercent =
+ progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
+
+ return (
+ <Dialog open={true}>
+ <DialogContent
+ className="sm:max-w-md"
+ onPointerDownOutside={(e) => e.preventDefault()}
+ >
+ <DialogHeader>
+ <DialogTitle>Updating Projects</DialogTitle>
+ <DialogDescription>
+ We're adding scene support to your projects. This will only take a
+ moment.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>Progress</span>
+ <span>
+ {progress.current} of {progress.total}
+ </span>
+ </div>
+ <Progress value={progressPercent} className="w-full" />
+ </div>
+
+ {progress.currentProjectName && (
+ <div className="text-sm text-muted-foreground">
+ {progress.current < progress.total
+ ? `Updating: ${progress.currentProjectName}`
+ : progress.currentProjectName}
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ return children;
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 0e07ccb8..56a10f44 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -16,9 +16,9 @@ const buttonVariants = cva(
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
- "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
+ "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
outline:
- "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
+ "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input",
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index ad7e825b..42d0f80d 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
- "fixed inset-0 z-150 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "fixed inset-0 z-250 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
- "fixed left-[50%] top-[50%] p-6 z-150 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
+ "fixed left-[50%] top-[50%] p-6 z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
className
)}
onCloseAutoFocus={(e) => {
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx
index c238b33c..9f5c7887 100644
--- a/apps/web/src/components/ui/draggable-item.tsx
+++ b/apps/web/src/components/ui/draggable-item.tsx
@@ -218,7 +218,7 @@ function PlusButton({
<Button
size="icon"
className={cn(
- "absolute bottom-2 right-2 size-4 bg-background hover:bg-panel text-foreground",
+ "absolute bottom-2 right-2 size-5 bg-background hover:bg-panel text-foreground",
className
)}
onClick={(e) => {
@@ -228,7 +228,7 @@ function PlusButton({
}}
title={tooltipText}
>
- <Plus className="size-3!" />
+ <Plus className="size-4!" />
</Button>
);
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
index ea225c14..cae9f8c9 100644
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ b/apps/web/src/components/ui/input-with-back.tsx
@@ -12,6 +12,7 @@ interface InputWithBackProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
+ disableAnimation?: boolean;
}
export function InputWithBack({
@@ -20,12 +21,13 @@ export function InputWithBack({
placeholder = "Search anything",
value,
onChange,
+ disableAnimation = false,
}: InputWithBackProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [buttonOffset, setButtonOffset] = useState(-60);
const smoothTransition = {
- duration: 0.35,
+ duration: disableAnimation ? 0 : 0.35,
ease: [0.25, 0.1, 0.25, 1] as const,
};
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 47782a7f..406ff8eb 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
- "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 2c5522c1..f97bd438 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "fixed inset-0 z-200 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
- "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
@@ -62,10 +62,14 @@ const SheetContent = React.forwardRef<
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
{...props}
>
- <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
- <X className="h-4 w-4" />
+ <SheetPrimitive.Close className="absolute cursor-pointer right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+ <X className="size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx
new file mode 100644
index 00000000..ca3b518c
--- /dev/null
+++ b/apps/web/src/components/ui/split-button.tsx
@@ -0,0 +1,90 @@
+import { Button, ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { ReactNode, forwardRef } from "react";
+import { cn } from "@/lib/utils";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
+ ({ children, className, ...props }, ref) => {
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "inline-flex rounded-full h-7 border border-input bg-panel-accent overflow-hidden",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+ }
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+ <Button
+ ref={ref}
+ variant="text"
+ className={cn(
+ "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors disabled:text-muted-foreground",
+ onClick
+ ? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer"
+ : "cursor-default select-text",
+ paddingClass,
+ className
+ )}
+ onClick={onClick}
+ {...props}
+ >
+ {typeof children === "string" ? (
+ <span className="font-normal cursor-text">{children}</span>
+ ) : (
+ children
+ )}
+ </Button>
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
+ }
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
+ }
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
+ ({ className, ...props }, ref) => {
+ return (
+ <Separator
+ ref={ref}
+ orientation="vertical"
+ className={cn("h-full bg-foreground/15", className)}
+ {...props}
+ />
+ );
+ }
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };