diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-31 13:12:02 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-31 13:12:02 +0200 |
| commit | b2cd730fb78d2dd647bd7bb7ea6691a83d160238 (patch) | |
| tree | baa05b7d44923e72918107d159b6afbd331ca2a4 /apps/web | |
| parent | 5f4f3cc6c420d5fe37bf8f8dffa9368e75b9b60e (diff) | |
| parent | f255ccb818358b90edd0c1bc1f3a690dc1e729ac (diff) | |
| download | opencut-b2cd730fb78d2dd647bd7bb7ea6691a83d160238.tar.gz | |
Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/package.json | 2 | ||||
| -rw-r--r-- | apps/web/src/app/contributors/page.tsx | 57 | ||||
| -rw-r--r-- | apps/web/src/components/editor/audio-waveform.tsx | 85 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 61 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/index.tsx | 63 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-element.tsx | 51 | ||||
| -rw-r--r-- | apps/web/src/components/icons.tsx | 50 | ||||
| -rw-r--r-- | apps/web/src/constants/site.ts | 22 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 97 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 19 | ||||
| -rw-r--r-- | apps/web/src/types/project.ts | 1 | ||||
| -rw-r--r-- | apps/web/src/types/timeline.ts | 1 |
14 files changed, 477 insertions, 37 deletions
diff --git a/apps/web/package.json b/apps/web/package.json index f5cd7da4..9531b6c7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,7 +43,7 @@ "input-otp": "^1.4.1", "lucide-react": "^0.468.0", "motion": "^12.18.1", - "next": "^15.4.3", + "next": "^15.4.5", "next-themes": "^0.4.4", "pg": "^8.16.2", "radix-ui": "^1.4.2", diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 6a524866..29151bd8 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -5,8 +5,14 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { ExternalLink } from "lucide-react"; import Link from "next/link"; -import { GithubIcon } from "@/components/icons"; +import { + GithubIcon, + MarbleIcon, + VercelIcon, + DataBuddyIcon, +} from "@/components/icons"; import { Badge } from "@/components/ui/badge"; +import { EXTERNAL_TOOLS } from "@/constants/site"; export const metadata: Metadata = { title: "Contributors - OpenCut", @@ -39,7 +45,7 @@ async function getContributors(): Promise<Contributor[]> { "User-Agent": "OpenCut-Web-App", }, next: { revalidate: 600 }, // 10 minutes - } + } as RequestInit ); if (!response.ok) { @@ -236,6 +242,53 @@ export default async function ContributorsPage() { </div> )} + <div className="mt-6 mb-20"> + <div className="text-center mb-12"> + <h2 className="text-2xl font-semibold mb-2">External Tools</h2> + <p className="text-muted-foreground"> + Tools we use to build OpenCut + </p> + </div> + + <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-4xl mx-auto"> + {EXTERNAL_TOOLS.map((tool, index) => { + const IconComponent = { + MarbleIcon, + VercelIcon, + DataBuddyIcon, + }[tool.icon]; + + return ( + <Link + key={tool.name} + href={tool.url} + target="_blank" + className="group block" + style={{ + animationDelay: `${index * 100}ms`, + }} + > + <Card className="h-full bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> + <CardContent className="p-6 text-center h-full flex flex-col"> + <div className="mb-4"> + <div className="w-12 h-12 mx-auto rounded-full bg-muted/50 flex items-center justify-center group-hover:bg-muted/70 transition-colors"> + <IconComponent className="h-6 w-6" size={24} /> + </div> + </div> + <h3 className="font-semibold text-lg mb-2 group-hover:text-foreground/80 transition-colors"> + {tool.name} + </h3> + <p className="text-sm text-muted-foreground flex-1"> + {tool.description} + </p> + </CardContent> + </Card> + </Link> + ); + })} + </div> + </div> + <div className="mt-32 text-center"> <div className="max-w-2xl mx-auto"> <h2 className="text-3xl font-bold mb-4">Join the community</h2> diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f995..372a31c5 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ useEffect(() => { let mounted = true; - + let ws = wavesurfer.current; + const initWaveSurfer = async () => { if (!waveformRef.current || !audioUrl) return; try { - // Clean up any existing instance - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } + // Clear any existing instance safely + if (ws) { + // Instead of immediately destroying, just set to null + // We'll destroy it outside this function wavesurfer.current = null; } - wavesurfer.current = WaveSurfer.create({ + // Create a fresh instance + const newWaveSurfer = WaveSurfer.create({ container: waveformRef.current, waveColor: "rgba(255, 255, 255, 0.6)", progressColor: "rgba(255, 255, 255, 0.9)", @@ -46,15 +45,28 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ interact: false, }); + // Assign to ref only if component is still mounted + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + // Component unmounted during initialization, clean up + try { + newWaveSurfer.destroy(); + } catch (e) { + // Ignore destroy errors + } + return; + } + // Event listeners - wavesurfer.current.on("ready", () => { + newWaveSurfer.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on("error", (err) => { + newWaveSurfer.on("error", (err) => { console.error("WaveSurfer error:", err); if (mounted) { setError(true); @@ -62,7 +74,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ } }); - await wavesurfer.current.load(audioUrl); + await newWaveSurfer.load(audioUrl); } catch (err) { console.error("Failed to initialize WaveSurfer:", err); if (mounted) { @@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ } }; - initWaveSurfer(); - - return () => { - mounted = false; - if (wavesurfer.current) { + // First safely destroy previous instance if it exists + if (ws) { + // Use this pattern to safely destroy the previous instance + const wsToDestroy = ws; + // Detach from ref immediately + wavesurfer.current = null; + + // Wait a tick to destroy so any pending operations can complete + requestAnimationFrame(() => { try { - wavesurfer.current.destroy(); + wsToDestroy.destroy(); } catch (e) { - // Silently ignore destroy errors + // Ignore errors during destroy + } + // Only initialize new instance after destroying the old one + if (mounted) { + initWaveSurfer(); } - wavesurfer.current = null; + }); + } else { + // No previous instance to clean up, initialize directly + initWaveSurfer(); + } + + return () => { + // Mark component as unmounted + mounted = false; + + // Store reference to current wavesurfer instance + const wsToDestroy = wavesurfer.current; + + // Immediately clear the ref to prevent accessing it after unmount + wavesurfer.current = null; + + // If we have an instance to clean up, do it safely + if (wsToDestroy) { + // Delay destruction to avoid race conditions + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch (e) { + // Ignore destroy errors - they're expected + } + }); } }; }, [audioUrl, height]); diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 4cc35bb5..55d52bed 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -234,6 +234,7 @@ export function PreviewPanel() { tracks.forEach((track) => { track.elements.forEach((element) => { + if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -826,6 +827,18 @@ function PreviewToolbar({ setCanvasSizeToOriginal(aspectRatio); }; + const totalDuration = getTotalDuration(); + + const skipBackward = () => { + const newTime = Math.max(0, currentTime - 1); + setCurrentTime(newTime); + }; + + const skipForward = () => { + const newTime = Math.min(totalDuration, currentTime + 1); + setCurrentTime(newTime); + }; + if (isExpanded) { return ( <FullscreenToolbar @@ -871,19 +884,41 @@ function PreviewToolbar({ </span> </p> </div> - <Button - variant="text" - size="icon" - onClick={toggle} - disabled={!hasAnyElements} - className="h-auto p-0" - > - {isPlaying ? ( - <Pause className="h-3 w-3" /> - ) : ( - <Play className="h-3 w-3" /> - )} - </Button> + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + onClick={skipBackward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip backward 1s" + > + <SkipBack className="h-3 w-3" /> + </Button> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + > + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + <Button + variant="text" + size="icon" + onClick={skipForward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip forward 1s" + > + <SkipForward className="h-3 w-3" /> + </Button> + </div> <div className="flex items-center gap-3"> <BackgroundSettings /> <DropdownMenu> diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 078f1258..9833adad 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -19,6 +19,7 @@ import { Link, ZoomIn, ZoomOut, + Bookmark, } from "lucide-react"; import { Tooltip, @@ -651,6 +652,30 @@ export function Timeline() { ); }).filter(Boolean); })()} + + {/* Bookmark markers */} + {(() => { + const { activeProject } = useProjectStore.getState(); + if (!activeProject?.bookmarks?.length) return null; + + return activeProject.bookmarks.map((bookmarkTime, i) => ( + <div + key={`bookmark-${i}`} + className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" + style={{ + left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + onClick={(e) => { + e.stopPropagation(); + usePlaybackStore.getState().seek(bookmarkTime); + }} + > + <div className="absolute top-[-1px] left-[-5px] text-primary"> + <Bookmark className="h-3 w-3 fill-primary" /> + </div> + </div> + )); + })()} </div> </ScrollArea> </div> @@ -773,6 +798,23 @@ export function Timeline() { <ContextMenuItem onClick={(e) => e.stopPropagation()}> Track settings (soon) </ContextMenuItem> + {activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && ( + <> + <ContextMenuItem disabled>Bookmarks</ContextMenuItem> + {activeProject.bookmarks.map((bookmarkTime, i) => ( + <ContextMenuItem + key={`bookmark-menu-${i}`} + onClick={(e) => { + e.stopPropagation(); + seek(bookmarkTime); + }} + > + <Bookmark className="h-3 w-3 mr-2 inline-block" /> + {bookmarkTime.toFixed(1)}s + </ContextMenuItem> + ))} + </> + )} </ContextMenuContent> </ContextMenu> ))} @@ -828,6 +870,7 @@ function TimelineToolbar({ toggleRippleEditing, } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { toggleBookmark, isBookmarked } = useProjectStore(); // Action handlers const handleSplitSelected = () => { @@ -957,6 +1000,13 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + // Check if the current time is bookmarked + 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"> @@ -1088,6 +1138,17 @@ function TimelineToolbar({ </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"> @@ -1121,6 +1182,8 @@ function TimelineToolbar({ </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" /> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index f581bbf0..96859c38 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -13,6 +13,10 @@ import { Type, Copy, RefreshCw, + EyeOff, + Eye, + Volume2, + VolumeX, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -66,11 +70,18 @@ export function TimelineElement({ addElementToTrack, replaceElementMedia, rippleEditingEnabled, + toggleElementHidden, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [elementMenuOpen, setElementMenuOpen] = useState(false); + const mediaItem = + element.type === "media" + ? mediaItems.find((item) => item.id === element.mediaId) + : null; + const isAudio = mediaItem?.type === "audio"; + const { resizing, isResizing, @@ -141,6 +152,11 @@ export function TimelineElement({ } }; + const handleToggleElementHidden = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleElementHidden(track.id, element.id); + }; + const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); if (element.type !== "media") { @@ -332,7 +348,7 @@ export function TimelineElement({ track.type )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ isBeingDragged ? "z-50" : "z-10" - }`} + } ${element.hidden ? "opacity-50" : ""}`} onClick={(e) => onElementClick && onElementClick(e, element)} onMouseDown={handleElementMouseDown} onContextMenu={(e) => @@ -343,6 +359,16 @@ export function TimelineElement({ {renderElementContent()} </div> + {element.hidden && ( + <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> + {isAudio ? ( + <VolumeX className="h-6 w-6 text-white" /> + ) : ( + <EyeOff className="h-6 w-6 text-white" /> + )} + </div> + )} + {isSelected && ( <> <div @@ -363,6 +389,29 @@ export function TimelineElement({ <Scissors className="h-4 w-4 mr-2" /> Split at playhead </ContextMenuItem> + <ContextMenuItem onClick={handleToggleElementHidden}> + {isAudio ? ( + element.hidden ? ( + <Volume2 className="h-4 w-4 mr-2" /> + ) : ( + <VolumeX className="h-4 w-4 mr-2" /> + ) + ) : element.hidden ? ( + <Eye className="h-4 w-4 mr-2" /> + ) : ( + <EyeOff className="h-4 w-4 mr-2" /> + )} + <span> + {isAudio + ? element.hidden + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {element.type === "text" ? "text" : "clip"} + </span> + </ContextMenuItem> <ContextMenuItem onClick={handleElementDuplicateContext}> <Copy className="h-4 w-4 mr-2" /> Duplicate {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 6099c052..43f953e8 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -113,3 +113,53 @@ export function BackgroundIcon({ className }: { className?: string }) { </svg> ); } + +export function MarbleIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={size} + height={size} + className={className} + fill="none" + viewBox="0 0 256 256" + > + <path fill="#202027" d="M0 0h256v256H0z" /> + <path + fill="#fff" + d="M116.032 94.016q1.408.256 6.272 9.856 4.856 9.472 11.904 24.576l4.096 8.576a42.4 42.4 0 0 0 4.992-6.784q2.304-3.96 5.76-10.624 3.96-7.56 5.504-9.728 6.144-9.344 13.312-19.84.896-1.408 3.2-1.92a4.8 4.8 0 0 1 1.28-.128q3.072 0 6.784 2.56 3.84 2.56 4.992 5.248a4.8 4.8 0 0 1 .256 1.92q0 1.28-.128 1.92l-.64 2.944q-.256 1.664-.512 3.968a3200 3200 0 0 0-5.888 46.848 56 56 0 0 0-.512 8.064l-.256 4.096a32 32 0 0 0-.128 2.944q0 1.672-.384 2.304-.384.64-1.408.64-.768 0-2.304-.384-4.096-.896-6.272-3.2-2.176-2.432-2.432-6.656-.128-2.176-.128-6.784 0-4.736.256-14.464l.128-7.04.256-10.112q-1.152 1.024-5.888 8.064a328 328 0 0 0-9.088 14.464q-4.48 7.56-5.888 11.648-1.28 3.2-2.56 4.736-1.152 1.536-2.816 1.536-1.536 0-3.968-1.408-6.912-4.224-8.448-11.648a336 336 0 0 0-6.016-23.68 40 40 0 0 0-2.56-6.272q-1.792-3.968-2.304-4.992l-1.664-3.328q-18.68 24.832-25.344 45.568-1.28 4.352-2.432 6.272-1.152 1.792-2.688 1.792-2.432 0-6.912-4.352Q72 158.144 72 154.304q0-2.04.768-4.096a108 108 0 0 1 16-30.08l3.968-5.248a432 432 0 0 0 12.16-16.64q3.072-4.48 8.192-4.48.896 0 2.944.256" + /> + </svg> + ); +} + +export function DataBuddyIcon({ + className = "", + size = 32, +}: { + className?: string; + size?: number; +}) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={size} + height={size} + className={className} + viewBox="0 0 8 8" + shapeRendering="crispEdges" + > + <path d="M0 0h8v8H0z" /> + <path + fill="#fff" + d="M1 1h1v6H1zm1 0h4v1H2zm4 1h1v1H6zm0 1h1v1H6zm0 1h1v1H6zm0 1h1v1H6zM2 6h4v1H2zm1-3h1v1H3zm1 1h1v1H4z" + /> + </svg> + ); +} diff --git a/apps/web/src/constants/site.ts b/apps/web/src/constants/site.ts index f47f6470..00bd649f 100644 --- a/apps/web/src/constants/site.ts +++ b/apps/web/src/constants/site.ts @@ -9,3 +9,25 @@ export const SITE_INFO = { twitterImage: "/open-graph/default.jpg", favicon: "/favicon.ico", }; + +export const EXTERNAL_TOOLS = [ + { + name: "Marble", + description: + "Modern headless CMS for content management and the blog for OpenCut", + url: "https://marblecms.com?utm_source=opencut", + icon: "MarbleIcon" as const, + }, + { + name: "Vercel", + description: "Platform where we deploy and host OpenCut", + url: "https://vercel.com?utm_source=opencut", + icon: "VercelIcon" as const, + }, + { + name: "Databuddy", + description: "GDPR compliant analytics and user insights for OpenCut", + url: "https://databuddy.cc?utm_source=opencut", + icon: "DataBuddyIcon" as const, + }, +]; diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 0037dbdb..2ff63ac7 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -63,6 +63,8 @@ class StorageService { backgroundColor: project.backgroundColor, backgroundType: project.backgroundType, blurIntensity: project.blurIntensity, + bookmarks: project.bookmarks, + fps: project.fps, }; await this.projectsAdapter.set(project.id, serializedProject); @@ -83,6 +85,8 @@ class StorageService { backgroundColor: serializedProject.backgroundColor, backgroundType: serializedProject.backgroundType, blurIntensity: serializedProject.blurIntensity, + bookmarks: serializedProject.bookmarks, + fps: serializedProject.fps, }; } diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index b662c164..0cfafd47 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -37,6 +37,7 @@ export interface StorageConfig { export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & { createdAt: string; updatedAt: string; + bookmarks?: number[]; }; // Extend FileSystemDirectoryHandle with missing async iterator methods diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 6744bdec..bfabba1f 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -29,6 +29,11 @@ interface ProjectStore { ) => Promise<void>; updateProjectFps: (fps: number) => Promise<void>; + // Bookmark methods + toggleBookmark: (time: number) => Promise<void>; + isBookmarked: (time: number) => boolean; + removeBookmark: (time: number) => Promise<void>; + getFilteredAndSortedProjects: ( searchQuery: string, sortOption: string @@ -47,6 +52,97 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ isInitialized: false, invalidProjectIds: new Set<string>(), + // Implementation of bookmark methods + toggleBookmark: async (time: number) => { + const { activeProject } = get(); + if (!activeProject) return; + + // Round time to the nearest frame + const fps = activeProject.fps || 30; + const frameTime = Math.round(time * fps) / fps; + + const bookmarks = activeProject.bookmarks || []; + let updatedBookmarks: number[]; + + // Check if already bookmarked + const bookmarkIndex = bookmarks.findIndex( + (bookmark) => Math.abs(bookmark - frameTime) < 0.001 + ); + + if (bookmarkIndex !== -1) { + // Remove bookmark + updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex); + } else { + // Add bookmark + updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b); + } + + const updatedProject = { + ...activeProject, + bookmarks: updatedBookmarks, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project bookmarks:", error); + toast.error("Failed to update bookmarks", { + description: "Please try again", + }); + } + }, + + isBookmarked: (time: number) => { + const { activeProject } = get(); + if (!activeProject || !activeProject.bookmarks) return false; + + // Round time to the nearest frame + const fps = activeProject.fps || 30; + const frameTime = Math.round(time * fps) / fps; + + return activeProject.bookmarks.some( + (bookmark) => Math.abs(bookmark - frameTime) < 0.001 + ); + }, + + removeBookmark: async (time: number) => { + const { activeProject } = get(); + if (!activeProject || !activeProject.bookmarks) return; + + // Round time to the nearest frame + const fps = activeProject.fps || 30; + const frameTime = Math.round(time * fps) / fps; + + const updatedBookmarks = activeProject.bookmarks.filter( + (bookmark) => Math.abs(bookmark - frameTime) >= 0.001 + ); + + if (updatedBookmarks.length === activeProject.bookmarks.length) { + // No bookmark found to remove + return; + } + + const updatedProject = { + ...activeProject, + bookmarks: updatedBookmarks, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project bookmarks:", error); + toast.error("Failed to remove bookmark", { + description: "Please try again", + }); + } + }, + createNewProject: async (name: string) => { const newProject: TProject = { id: generateUUID(), @@ -57,6 +153,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ backgroundColor: "#000000", backgroundType: "color", blurIntensity: 8, + bookmarks: [], }; set({ activeProject: newProject }); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f3497be9..0f441176 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -126,6 +126,7 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; + toggleElementHidden: (trackId: string, elementId: string) => void; // Split operations for elements splitElement: ( @@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); }, + toggleElementHidden: (trackId, elementId) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, hidden: !element.hidden } + : element + ), + } + : track + ) + ); + }, + updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index 58d37b03..f52cba73 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -9,4 +9,5 @@ export interface TProject { backgroundType?: "color" | "blur"; blurIntensity?: number; // in pixels (4, 8, 18) fps?: number; + bookmarks?: number[]; } diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index ef20c642..cd17b7dd 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -11,6 +11,7 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + hidden?: boolean; } // Media element that references MediaStore |
