From 5931ddb77aac23ffe1b82d9d7c03afde3e671109 Mon Sep 17 00:00:00 2001 From: Hayden Bleasel Date: Thu, 24 Jul 2025 14:41:34 -0700 Subject: feat: add ultracite (#452) * Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format --- apps/web/src/components/background-settings.tsx | 368 ++-- apps/web/src/components/editor-header.tsx | 250 +-- apps/web/src/components/editor-provider.tsx | 106 +- apps/web/src/components/editor/audio-waveform.tsx | 42 +- .../components/editor/media-panel/drag-overlay.tsx | 114 +- .../src/components/editor/media-panel/index.tsx | 108 +- .../web/src/components/editor/media-panel/store.ts | 146 +- .../src/components/editor/media-panel/tabbar.tsx | 248 +-- .../components/editor/media-panel/views/audio.tsx | 36 +- .../components/editor/media-panel/views/media.tsx | 596 ++--- .../components/editor/media-panel/views/text.tsx | 106 +- apps/web/src/components/editor/preview-panel.tsx | 4 +- .../editor/properties-panel/audio-properties.tsx | 10 +- .../editor/properties-panel/media-properties.tsx | 10 +- .../editor/properties-panel/property-item.tsx | 94 +- .../editor/properties-panel/text-properties.tsx | 152 +- apps/web/src/components/editor/selection-box.tsx | 116 +- apps/web/src/components/editor/snap-indicator.tsx | 2 +- apps/web/src/components/editor/speed-control.tsx | 2 +- apps/web/src/components/editor/timeline/index.tsx | 27 +- .../editor/timeline/timeline-playhead.tsx | 232 +- .../components/editor/timeline/timeline-track.tsx | 2302 ++++++++++---------- apps/web/src/components/header-base.tsx | 82 +- apps/web/src/components/header.tsx | 94 +- apps/web/src/components/icons.tsx | 13 +- apps/web/src/components/landing/handlebars.tsx | 6 +- apps/web/src/components/onboarding.tsx | 242 +- apps/web/src/components/rename-project-dialog.tsx | 146 +- apps/web/src/components/storage-provider.tsx | 160 +- apps/web/src/components/ui/aspect-ratio.tsx | 8 +- apps/web/src/components/ui/audio-player.tsx | 254 +-- apps/web/src/components/ui/button.tsx | 3 +- apps/web/src/components/ui/card.tsx | 5 +- apps/web/src/components/ui/carousel.tsx | 2 +- apps/web/src/components/ui/chart.tsx | 4 +- apps/web/src/components/ui/collapsible.tsx | 12 +- apps/web/src/components/ui/font-picker.tsx | 80 +- apps/web/src/components/ui/form.tsx | 4 +- .../src/components/ui/image-timeline-treatment.tsx | 204 +- apps/web/src/components/ui/phone-input.tsx | 334 +-- apps/web/src/components/ui/separator.tsx | 14 +- apps/web/src/components/ui/sonner.tsx | 16 +- apps/web/src/components/ui/sponsor-button.tsx | 1 - apps/web/src/components/ui/toaster.tsx | 24 +- 44 files changed, 3391 insertions(+), 3388 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index 721564ea..a640dc4c 100644 --- a/apps/web/src/components/background-settings.tsx +++ b/apps/web/src/components/background-settings.tsx @@ -1,184 +1,184 @@ -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { Button } from "./ui/button"; -import { BackgroundIcon } from "./icons"; -import { cn } from "@/lib/utils"; -import Image from "next/image"; -import { colors } from "@/data/colors"; -import { useProjectStore } from "@/stores/project-store"; -import { PipetteIcon } from "lucide-react"; - -type BackgroundTab = "color" | "blur"; - -export function BackgroundSettings() { - const { activeProject, updateBackgroundType } = useProjectStore(); - - // ✅ Good: derive activeTab from activeProject during rendering - const activeTab = activeProject?.backgroundType || "color"; - - const handleColorSelect = (color: string) => { - updateBackgroundType("color", { backgroundColor: color }); - }; - - const handleBlurSelect = (blurIntensity: number) => { - updateBackgroundType("blur", { blurIntensity }); - }; - - const tabs = [ - { - label: "Color", - value: "color", - }, - { - label: "Blur", - value: "blur", - }, - ]; - - return ( - - - - - -
-

Background

-
- {tabs.map((tab) => ( - { - // Switch to the background type when clicking tabs - if (tab.value === "color") { - updateBackgroundType("color", { - backgroundColor: - activeProject?.backgroundColor || "#000000", - }); - } else { - updateBackgroundType("blur", { - blurIntensity: activeProject?.blurIntensity || 8, - }); - } - }} - className={cn( - "text-muted-foreground cursor-pointer", - activeTab === tab.value && "text-foreground" - )} - > - {tab.label} - - ))} -
-
- {activeTab === "color" ? ( - - ) : ( - - )} -
-
- ); -} - -function ColorView({ - selectedColor, - onColorSelect, -}: { - selectedColor: string; - onColorSelect: (color: string) => void; -}) { - return ( -
-
-
-
- -
- {colors.map((color) => ( - onColorSelect(color)} - /> - ))} -
-
- ); -} - -function ColorItem({ - color, - isSelected, - onClick, -}: { - color: string; - isSelected: boolean; - onClick: () => void; -}) { - return ( -
- ); -} - -function BlurView({ - selectedBlur, - onBlurSelect, -}: { - selectedBlur: number; - onBlurSelect: (blurIntensity: number) => void; -}) { - const blurLevels = [ - { label: "Light", value: 4 }, - { label: "Medium", value: 8 }, - { label: "Heavy", value: 18 }, - ]; - const blurImage = - "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; - - return ( -
- {blurLevels.map((blur) => ( -
onBlurSelect(blur.value)} - > - {`Blur -
- - {blur.label} - -
-
- ))} -
- ); -} +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { Button } from "./ui/button"; +import { BackgroundIcon } from "./icons"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { colors } from "@/data/colors"; +import { useProjectStore } from "@/stores/project-store"; +import { PipetteIcon } from "lucide-react"; + +type BackgroundTab = "color" | "blur"; + +export function BackgroundSettings() { + const { activeProject, updateBackgroundType } = useProjectStore(); + + // ✅ Good: derive activeTab from activeProject during rendering + const activeTab = activeProject?.backgroundType || "color"; + + const handleColorSelect = (color: string) => { + updateBackgroundType("color", { backgroundColor: color }); + }; + + const handleBlurSelect = (blurIntensity: number) => { + updateBackgroundType("blur", { blurIntensity }); + }; + + const tabs = [ + { + label: "Color", + value: "color", + }, + { + label: "Blur", + value: "blur", + }, + ]; + + return ( + + + + + +
+

Background

+
+ {tabs.map((tab) => ( + { + // Switch to the background type when clicking tabs + if (tab.value === "color") { + updateBackgroundType("color", { + backgroundColor: + activeProject?.backgroundColor || "#000000", + }); + } else { + updateBackgroundType("blur", { + blurIntensity: activeProject?.blurIntensity || 8, + }); + } + }} + className={cn( + "text-muted-foreground cursor-pointer", + activeTab === tab.value && "text-foreground" + )} + > + {tab.label} + + ))} +
+
+ {activeTab === "color" ? ( + + ) : ( + + )} +
+
+ ); +} + +function ColorView({ + selectedColor, + onColorSelect, +}: { + selectedColor: string; + onColorSelect: (color: string) => void; +}) { + return ( +
+
+
+
+ +
+ {colors.map((color) => ( + onColorSelect(color)} + /> + ))} +
+
+ ); +} + +function ColorItem({ + color, + isSelected, + onClick, +}: { + color: string; + isSelected: boolean; + onClick: () => void; +}) { + return ( +
+ ); +} + +function BlurView({ + selectedBlur, + onBlurSelect, +}: { + selectedBlur: number; + onBlurSelect: (blurIntensity: number) => void; +}) { + const blurLevels = [ + { label: "Light", value: 4 }, + { label: "Medium", value: 8 }, + { label: "Heavy", value: 18 }, + ]; + const blurImage = + "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; + + return ( +
+ {blurLevels.map((blur) => ( +
onBlurSelect(blur.value)} + > + {`Blur +
+ + {blur.label} + +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 7daf5270..6e9af186 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,125 +1,125 @@ -"use client"; - -import Link from "next/link"; -import { Button } from "./ui/button"; -import { ChevronLeft, Download } from "lucide-react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { HeaderBase } from "./header-base"; -import { formatTimeCode } from "@/lib/time"; -import { useProjectStore } from "@/stores/project-store"; -import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; -import { useState, useRef } from "react"; -import { Input } from "@/components/ui/input"; - -export function EditorHeader() { - const { getTotalDuration } = useTimelineStore(); - const { activeProject, renameProject } = useProjectStore(); - const [isEditing, setIsEditing] = useState(false); - const [newName, setNewName] = useState(activeProject?.name || ""); - const inputRef = useRef(null); - - const handleExport = () => { - // TODO: Implement export functionality - // NOTE: This is already being worked on - console.log("Export project"); - }; - - const handleNameClick = () => { - if (!activeProject) return; - setNewName(activeProject.name); - setIsEditing(true); - }; - - const handleNameSave = async () => { - if (activeProject && newName.trim() && newName !== activeProject.name) { - try { - await renameProject(activeProject.id, newName.trim()); - } catch (error) { - console.error("Failed to rename project:", error); - setNewName(activeProject.name); - } - } - setIsEditing(false); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") handleNameSave(); - else if (e.key === "Escape") setIsEditing(false); - }; - - const leftContent = ( -
- - - -
- {isEditing ? ( - setNewName(e.target.value)} - onBlur={handleNameSave} - onKeyDown={handleInputKeyDown} - onFocus={(e) => e.target.select()} - maxLength={64} - aria-label="Project name" - autoFocus - /> - ) : ( - e.key === "Enter" && handleNameClick()} - > -
- {activeProject?.name} -
-
- )} -
-
- ); - - const centerContent = ( -
- - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - -
- ); - - const rightContent = ( - - ); - - return ( - - ); -} +"use client"; + +import Link from "next/link"; +import { Button } from "./ui/button"; +import { ChevronLeft, Download } from "lucide-react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { HeaderBase } from "./header-base"; +import { formatTimeCode } from "@/lib/time"; +import { useProjectStore } from "@/stores/project-store"; +import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; +import { useState, useRef } from "react"; +import { Input } from "@/components/ui/input"; + +export function EditorHeader() { + const { getTotalDuration } = useTimelineStore(); + const { activeProject, renameProject } = useProjectStore(); + const [isEditing, setIsEditing] = useState(false); + const [newName, setNewName] = useState(activeProject?.name || ""); + const inputRef = useRef(null); + + const handleExport = () => { + // TODO: Implement export functionality + // NOTE: This is already being worked on + console.log("Export project"); + }; + + const handleNameClick = () => { + if (!activeProject) return; + setNewName(activeProject.name); + setIsEditing(true); + }; + + const handleNameSave = async () => { + if (activeProject && newName.trim() && newName !== activeProject.name) { + try { + await renameProject(activeProject.id, newName.trim()); + } catch (error) { + console.error("Failed to rename project:", error); + setNewName(activeProject.name); + } + } + setIsEditing(false); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleNameSave(); + else if (e.key === "Escape") setIsEditing(false); + }; + + const leftContent = ( +
+ + + +
+ {isEditing ? ( + setNewName(e.target.value)} + onBlur={handleNameSave} + onKeyDown={handleInputKeyDown} + onFocus={(e) => e.target.select()} + maxLength={64} + aria-label="Project name" + autoFocus + /> + ) : ( + e.key === "Enter" && handleNameClick()} + > +
+ {activeProject?.name} +
+
+ )} +
+
+ ); + + const centerContent = ( +
+ + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + +
+ ); + + const rightContent = ( + + ); + + return ( + + ); +} diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index 25d040db..b8e9ae1d 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -1,53 +1,53 @@ -"use client"; - -import { useEffect } from "react"; -import { Loader2 } from "lucide-react"; -import { useEditorStore } from "@/stores/editor-store"; -import { - useKeybindingsListener, - useKeybindingDisabler, -} from "@/hooks/use-keybindings"; -import { useEditorActions } from "@/hooks/use-editor-actions"; - -interface EditorProviderProps { - children: React.ReactNode; -} - -export function EditorProvider({ children }: EditorProviderProps) { - const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); - const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); - - // Set up action handlers - useEditorActions(); - - // Set up keybinding listener - useKeybindingsListener(); - - // Disable keybindings when initializing - useEffect(() => { - if (isInitializing || !isPanelsReady) { - disableKeybindings(); - } else { - enableKeybindings(); - } - }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); - - useEffect(() => { - initializeApp(); - }, [initializeApp]); - - // Show loading screen while initializing - if (isInitializing || !isPanelsReady) { - return ( -
-
- -

Loading editor...

-
-
- ); - } - - // App is ready, render children - return <>{children}; -} +"use client"; + +import { useEffect } from "react"; +import { Loader2 } from "lucide-react"; +import { useEditorStore } from "@/stores/editor-store"; +import { + useKeybindingsListener, + useKeybindingDisabler, +} from "@/hooks/use-keybindings"; +import { useEditorActions } from "@/hooks/use-editor-actions"; + +interface EditorProviderProps { + children: React.ReactNode; +} + +export function EditorProvider({ children }: EditorProviderProps) { + const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); + + // Set up action handlers + useEditorActions(); + + // Set up keybinding listener + useKeybindingsListener(); + + // Disable keybindings when initializing + useEffect(() => { + if (isInitializing || !isPanelsReady) { + disableKeybindings(); + } else { + enableKeybindings(); + } + }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); + + useEffect(() => { + initializeApp(); + }, [initializeApp]); + + // Show loading screen while initializing + if (isInitializing || !isPanelsReady) { + return ( +
+
+ +

Loading editor...

+
+
+ ); + } + + // App is ready, render children + return <>{children}; +} diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index 75a5ae06..a673f995 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; -import WaveSurfer from 'wavesurfer.js'; +import React, { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; interface AudioWaveformProps { audioUrl: string; @@ -7,10 +7,10 @@ interface AudioWaveformProps { className?: string; } -const AudioWaveform: React.FC = ({ - audioUrl, - height = 32, - className = '' +const AudioWaveform: React.FC = ({ + audioUrl, + height = 32, + className = "", }) => { const waveformRef = useRef(null); const wavesurfer = useRef(null); @@ -36,26 +36,26 @@ const AudioWaveform: React.FC = ({ wavesurfer.current = WaveSurfer.create({ container: waveformRef.current, - waveColor: 'rgba(255, 255, 255, 0.6)', - progressColor: 'rgba(255, 255, 255, 0.9)', - cursorColor: 'transparent', + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", barWidth: 2, barGap: 1, - height: height, + height, normalize: true, interact: false, }); // Event listeners - wavesurfer.current.on('ready', () => { + wavesurfer.current.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on('error', (err) => { - console.error('WaveSurfer error:', err); + wavesurfer.current.on("error", (err) => { + console.error("WaveSurfer error:", err); if (mounted) { setError(true); setIsLoading(false); @@ -63,9 +63,8 @@ const AudioWaveform: React.FC = ({ }); await wavesurfer.current.load(audioUrl); - } catch (err) { - console.error('Failed to initialize WaveSurfer:', err); + console.error("Failed to initialize WaveSurfer:", err); if (mounted) { setError(true); setIsLoading(false); @@ -90,7 +89,10 @@ const AudioWaveform: React.FC = ({ if (error) { return ( -
+
Audio unavailable
); @@ -103,13 +105,13 @@ const AudioWaveform: React.FC = ({ Loading...
)} -
); }; -export default AudioWaveform; \ No newline at end of file +export default AudioWaveform; diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx index 3f3820db..e1fd257e 100644 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -1,57 +1,57 @@ -import { Upload, Plus, Image } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface MediaDragOverlayProps { - isVisible: boolean; - isProcessing?: boolean; - progress?: number; - onClick?: () => void; - isEmptyState?: boolean; -} - -export function MediaDragOverlay({ - isVisible, - isProcessing = false, - progress = 0, - onClick, - isEmptyState = false, -}: MediaDragOverlayProps) { - if (!isVisible) return null; - - const handleClick = (e: React.MouseEvent) => { - if (isProcessing || !onClick) return; - e.preventDefault(); - e.stopPropagation(); - onClick(); - }; - - return ( -
-
- -
- -
-

- {isProcessing - ? `Processing your files (${progress}%)` - : "Drag and drop videos, photos, and audio files here"} -

-
- - {isProcessing && ( -
-
-
-
-
- )} -
- ); -} +import { Upload, Plus, Image } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; + isEmptyState?: boolean; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, + isEmptyState = false, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = (e: React.MouseEvent) => { + if (isProcessing || !onClick) return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }; + + return ( +
+
+ +
+ +
+

+ {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} +

+
+ + {isProcessing && ( +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 90ac84f0..2c743618 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -1,54 +1,54 @@ -"use client"; - -import { TabBar } from "./tabbar"; -import { MediaView } from "./views/media"; -import { useMediaPanelStore, Tab } from "./store"; -import { TextView } from "./views/text"; -import { AudioView } from "./views/audio"; - -export function MediaPanel() { - const { activeTab } = useMediaPanelStore(); - - const viewMap: Record = { - media: , - audio: , - text: , - stickers: ( -
- Stickers view coming soon... -
- ), - effects: ( -
- Effects view coming soon... -
- ), - transitions: ( -
- Transitions view coming soon... -
- ), - captions: ( -
- Captions view coming soon... -
- ), - filters: ( -
- Filters view coming soon... -
- ), - adjustment: ( -
- Adjustment view coming soon... -
- ), - }; - - return ( -
- -
{viewMap[activeTab]}
-
- ); -} +"use client"; + +import { TabBar } from "./tabbar"; +import { MediaView } from "./views/media"; +import { useMediaPanelStore, Tab } from "./store"; +import { TextView } from "./views/text"; +import { AudioView } from "./views/audio"; + +export function MediaPanel() { + const { activeTab } = useMediaPanelStore(); + + const viewMap: Record = { + media: , + audio: , + text: , + stickers: ( +
+ Stickers view coming soon... +
+ ), + effects: ( +
+ Effects view coming soon... +
+ ), + transitions: ( +
+ Transitions view coming soon... +
+ ), + captions: ( +
+ Captions view coming soon... +
+ ), + filters: ( +
+ Filters view coming soon... +
+ ), + adjustment: ( +
+ Adjustment view coming soon... +
+ ), + }; + + return ( +
+ +
{viewMap[activeTab]}
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 9668f285..c661f51e 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -1,73 +1,73 @@ -import { - CaptionsIcon, - ArrowLeftRightIcon, - SparklesIcon, - StickerIcon, - MusicIcon, - VideoIcon, - BlendIcon, - SlidersHorizontalIcon, - LucideIcon, - TypeIcon, -} from "lucide-react"; -import { create } from "zustand"; - -export type Tab = - | "media" - | "audio" - | "text" - | "stickers" - | "effects" - | "transitions" - | "captions" - | "filters" - | "adjustment"; - -export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { - media: { - icon: VideoIcon, - label: "Media", - }, - audio: { - icon: MusicIcon, - label: "Audio", - }, - text: { - icon: TypeIcon, - label: "Text", - }, - stickers: { - icon: StickerIcon, - label: "Stickers", - }, - effects: { - icon: SparklesIcon, - label: "Effects", - }, - transitions: { - icon: ArrowLeftRightIcon, - label: "Transitions", - }, - captions: { - icon: CaptionsIcon, - label: "Captions", - }, - filters: { - icon: BlendIcon, - label: "Filters", - }, - adjustment: { - icon: SlidersHorizontalIcon, - label: "Adjustment", - }, -}; - -interface MediaPanelStore { - activeTab: Tab; - setActiveTab: (tab: Tab) => void; -} - -export const useMediaPanelStore = create((set) => ({ - activeTab: "media", - setActiveTab: (tab) => set({ activeTab: tab }), -})); +import { + CaptionsIcon, + ArrowLeftRightIcon, + SparklesIcon, + StickerIcon, + MusicIcon, + VideoIcon, + BlendIcon, + SlidersHorizontalIcon, + LucideIcon, + TypeIcon, +} from "lucide-react"; +import { create } from "zustand"; + +export type Tab = + | "media" + | "audio" + | "text" + | "stickers" + | "effects" + | "transitions" + | "captions" + | "filters" + | "adjustment"; + +export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { + media: { + icon: VideoIcon, + label: "Media", + }, + audio: { + icon: MusicIcon, + label: "Audio", + }, + text: { + icon: TypeIcon, + label: "Text", + }, + stickers: { + icon: StickerIcon, + label: "Stickers", + }, + effects: { + icon: SparklesIcon, + label: "Effects", + }, + transitions: { + icon: ArrowLeftRightIcon, + label: "Transitions", + }, + captions: { + icon: CaptionsIcon, + label: "Captions", + }, + filters: { + icon: BlendIcon, + label: "Filters", + }, + adjustment: { + icon: SlidersHorizontalIcon, + label: "Adjustment", + }, +}; + +interface MediaPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +} + +export const useMediaPanelStore = create((set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), +})); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index b05bee7e..bce84a46 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -1,124 +1,124 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Tab, tabs, useMediaPanelStore } from "./store"; -import { Button } from "@/components/ui/button"; -import { ChevronRight, ChevronLeft } from "lucide-react"; -import { useRef, useState, useEffect } from "react"; - -export function TabBar() { - const { activeTab, setActiveTab } = useMediaPanelStore(); - const scrollContainerRef = useRef(null); - const [isAtEnd, setIsAtEnd] = useState(false); - const [isAtStart, setIsAtStart] = useState(true); - - const scrollToEnd = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - left: scrollContainerRef.current.scrollWidth, - }); - setIsAtEnd(true); - setIsAtStart(false); - } - }; - - const scrollToStart = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - left: 0, - }); - setIsAtStart(true); - setIsAtEnd(false); - } - }; - - const checkScrollPosition = () => { - if (scrollContainerRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = - scrollContainerRef.current; - const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; - const isAtStartNow = scrollLeft <= 1; - setIsAtEnd(isAtEndNow); - setIsAtStart(isAtStartNow); - } - }; - - // We're using useEffect because we need to sync with external DOM scroll events - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - checkScrollPosition(); - container.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(container); - - return () => { - container.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, []); - - return ( -
- -
- {(Object.keys(tabs) as Tab[]).map((tabKey) => { - const tab = tabs[tabKey]; - return ( -
setActiveTab(tabKey)} - key={tabKey} - > - - {tab.label} -
- ); - })} -
- -
- ); -} - -function ScrollButton({ - direction, - onClick, - isVisible, -}: { - direction: "left" | "right"; - onClick: () => void; - isVisible: boolean; -}) { - if (!isVisible) return null; - - const Icon = direction === "left" ? ChevronLeft : ChevronRight; - - return ( -
- -
- ); -} +"use client"; + +import { cn } from "@/lib/utils"; +import { Tab, tabs, useMediaPanelStore } from "./store"; +import { Button } from "@/components/ui/button"; +import { ChevronRight, ChevronLeft } from "lucide-react"; +import { useRef, useState, useEffect } from "react"; + +export function TabBar() { + const { activeTab, setActiveTab } = useMediaPanelStore(); + const scrollContainerRef = useRef(null); + const [isAtEnd, setIsAtEnd] = useState(false); + const [isAtStart, setIsAtStart] = useState(true); + + const scrollToEnd = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: scrollContainerRef.current.scrollWidth, + }); + setIsAtEnd(true); + setIsAtStart(false); + } + }; + + const scrollToStart = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: 0, + }); + setIsAtStart(true); + setIsAtEnd(false); + } + }; + + const checkScrollPosition = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = + scrollContainerRef.current; + const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; + const isAtStartNow = scrollLeft <= 1; + setIsAtEnd(isAtEndNow); + setIsAtStart(isAtStartNow); + } + }; + + // We're using useEffect because we need to sync with external DOM scroll events + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + checkScrollPosition(); + container.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, []); + + return ( +
+ +
+ {(Object.keys(tabs) as Tab[]).map((tabKey) => { + const tab = tabs[tabKey]; + return ( +
setActiveTab(tabKey)} + key={tabKey} + > + + {tab.label} +
+ ); + })} +
+ +
+ ); +} + +function ScrollButton({ + direction, + onClick, + isVisible, +}: { + direction: "left" | "right"; + onClick: () => void; + isVisible: boolean; +}) { + if (!isVisible) return null; + + const Icon = direction === "left" ? ChevronLeft : ChevronRight; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx index 7ddf465c..ba64d749 100644 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -1,18 +1,18 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState } from "react"; - -export function AudioView() { - const [search, setSearch] = useState(""); - return ( -
- setSearch(e.target.value)} - /> -
-
- ); -} +"use client"; + +import { Input } from "@/components/ui/input"; +import { useState } from "react"; + +export function AudioView() { + const [search, setSearch] = useState(""); + return ( +
+ setSearch(e.target.value)} + /> +
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 0189720f..66d0d00d 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,298 +1,298 @@ -"use client"; - -import { useDragDrop } from "@/hooks/use-drag-drop"; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { Image, Loader2, Music, Plus, Video } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; - -export function MediaView() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const fileInputRef = useRef(null); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [mediaFilter, setMediaFilter] = useState("all"); - - const processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - if (!activeProject) { - toast.error("No active project"); - return; - } - - setIsProcessing(true); - setProgress(0); - try { - // Process files (extract metadata, generate thumbnails, etc.) - const processedItems = await processMediaFiles(files, (p) => - setProgress(p) - ); - // Add each processed media item to the store - for (const item of processedItems) { - await addMediaItem(activeProject.id, item); - } - } catch (error) { - // Show error toast if processing fails - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; - - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); - - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - - const handleFileChange = (e: React.ChangeEvent) => { - // When files are selected via file picker, process them - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; - - const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store - e.stopPropagation(); - - if (!activeProject) { - toast.error("No active project"); - return; - } - - // Media store now handles cascade deletion automatically - await removeMediaItem(activeProject.id, id); - }; - - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; - - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - const filtered = mediaItems.filter((item) => { - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - - return true; - }); - - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); - - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( -
- {item.name} -
- ); - } - - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
- {item.name} -
-
- {item.duration && ( -
- {formatDuration(item.duration)} -
- )} -
- ); - } - return ( -
-
- ); - } - - if (item.type === "audio") { - return ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } - - return ( -
- - Unknown -
- ); - }; - - return ( - <> - {/* Hidden file input for uploading media */} - - -
-
- {/* Search and filter controls */} -
- - setSearchQuery(e.target.value)} - /> - -
-
- -
- {isDragOver || filteredMediaItems.length === 0 ? ( - - ) : ( -
- {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - - - - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - - - Export clips - handleRemove(e, item.id)} - > - Delete - - - - ))} -
- )} -
-
- - ); -} +"use client"; + +import { useDragDrop } from "@/hooks/use-drag-drop"; +import { processMediaFiles } from "@/lib/media-processing"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { Image, Loader2, Music, Plus, Video } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function MediaView() { + const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const fileInputRef = useRef(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [mediaFilter, setMediaFilter] = useState("all"); + + const processFiles = async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + // Process files (extract metadata, generate thumbnails, etc.) + const processedItems = await processMediaFiles(files, (p) => + setProgress(p) + ); + // Add each processed media item to the store + for (const item of processedItems) { + await addMediaItem(activeProject.id, item); + } + } catch (error) { + // Show error toast if processing fails + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps } = useDragDrop({ + // When files are dropped, process them + onDrop: processFiles, + }); + + const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker + + const handleFileChange = (e: React.ChangeEvent) => { + // When files are selected via file picker, process them + if (e.target.files) processFiles(e.target.files); + e.target.value = ""; // Reset input + }; + + const handleRemove = async (e: React.MouseEvent, id: string) => { + // Remove a media item from the store + e.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + // Media store now handles cascade deletion automatically + await removeMediaItem(activeProject.id, id); + }; + + const formatDuration = (duration: number) => { + // Format seconds as mm:ss + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; + }; + + const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); + + useEffect(() => { + const filtered = mediaItems.filter((item) => { + if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { + return false; + } + + if ( + searchQuery && + !item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + return false; + } + + return true; + }); + + setFilteredMediaItems(filtered); + }, [mediaItems, mediaFilter, searchQuery]); + + const renderPreview = (item: MediaItem) => { + // Render a preview for each media type (image, video, audio, unknown) + if (item.type === "image") { + return ( +
+ {item.name} +
+ ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( +
+ {item.name} +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
+ ); + } + return ( +
+
+ ); + } + + if (item.type === "audio") { + return ( +
+ + Audio + {item.duration && ( + + {formatDuration(item.duration)} + + )} +
+ ); + } + + return ( +
+ + Unknown +
+ ); + }; + + return ( + <> + {/* Hidden file input for uploading media */} + + +
+
+ {/* Search and filter controls */} +
+ + setSearchQuery(e.target.value)} + /> + +
+
+ +
+ {isDragOver || filteredMediaItems.length === 0 ? ( + + ) : ( +
+ {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + + + + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + + + Export clips + handleRemove(e, item.id)} + > + Delete + + + + ))} +
+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 6d0d310b..0686190e 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,53 +1,53 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { type TextElement } from "@/types/timeline"; - -let textData: TextElement = { - id: "default-text", - type: "text", - name: "Default text", - content: "Default text", - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center" as const, - fontWeight: "normal" as const, - fontStyle: "normal" as const, - textDecoration: "none" as const, - x: 0, - y: 0, - rotation: 0, - opacity: 1, - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, -}; - -export function TextView() { - return ( -
- - Default text -
- } - dragData={{ - id: textData.id, - type: textData.type, - name: textData.name, - content: textData.content, - }} - aspectRatio={1} - onAddToTimeline={(currentTime) => - useTimelineStore.getState().addTextAtTime(textData, currentTime) - } - showLabel={false} - /> -
- ); -} +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { type TextElement } from "@/types/timeline"; + +const textData: TextElement = { + id: "default-text", + type: "text", + name: "Default text", + content: "Default text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center" as const, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; + +export function TextView() { + return ( +
+ + Default text +
+ } + dragData={{ + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, + }} + aspectRatio={1} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } + showLabel={false} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2cba1374..f09e8462 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -368,7 +368,7 @@ export function PreviewPanel() { ref={containerRef} className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" > -
+
{hasAnyElements ? (
) : null} -
+
Audio properties
; -} \ No newline at end of file +import { MediaElement } from "@/types/timeline"; + +export function AudioProperties({ element }: { element: MediaElement }) { + return
Audio properties
; +} diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx index c2c514cf..4ea28bb2 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline"; - -export function MediaProperties({ element }: { element: MediaElement }) { - return
Media properties
; -} +import { MediaElement } from "@/types/timeline"; + +export function MediaProperties({ element }: { element: MediaElement }) { + return
Media properties
; +} diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index beb8c799..804ffb39 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -1,47 +1,47 @@ -import { cn } from "@/lib/utils"; - -interface PropertyItemProps { - direction?: "row" | "column"; - children: React.ReactNode; - className?: string; -} - -export function PropertyItem({ - direction = "row", - children, - className, -}: PropertyItemProps) { - return ( -
- {children} -
- ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ; -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return
{children}
; -} +import { cn } from "@/lib/utils"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( +
+ {children} +
+ ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ; +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index e13dabc8..c55c3dae 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -1,76 +1,76 @@ -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import { FontFamily } from "@/constants/font-constants"; -import { TextElement } from "@/types/timeline"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; - -export function TextProperties({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const { updateTextElement } = useTimelineStore(); - - return ( -
-