diff options
| author | Hyteq <DerpsDer.es@gmail.com> | 2025-07-20 11:00:22 +0300 |
|---|---|---|
| committer | Hyteq <DerpsDer.es@gmail.com> | 2025-07-20 11:00:22 +0300 |
| commit | be1ae20d9089e81cb84a8294be76fe680a24b60f (patch) | |
| tree | 48472c1122d35f45f802bbd1c3cf2c15f653f9cf /apps/web/src/components | |
| parent | ede08dac416b47eac2db151969c164bdf0f06002 (diff) | |
| download | opencut-be1ae20d9089e81cb84a8294be76fe680a24b60f.tar.gz | |
format
Diffstat (limited to 'apps/web/src/components')
89 files changed, 9541 insertions, 9514 deletions
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index 721564ea..1d80465b 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 (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="text"
- size="icon"
- className="!size-4 border border-muted-foreground"
- >
- <BackgroundIcon className="!size-3" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
- <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
- <h2 className="text-sm">Background</h2>
- <div className="flex items-center gap-2 text-sm">
- {tabs.map((tab) => (
- <span
- key={tab.value}
- onClick={() => {
- // 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}
- </span>
- ))}
- </div>
- </div>
- {activeTab === "color" ? (
- <ColorView
- selectedColor={activeProject?.backgroundColor || "#000000"}
- onColorSelect={handleColorSelect}
- />
- ) : (
- <BlurView
- selectedBlur={activeProject?.blurIntensity || 8}
- onBlurSelect={handleBlurSelect}
- />
- )}
- </PopoverContent>
- </Popover>
- );
-}
-
-function ColorView({
- selectedColor,
- onColorSelect,
-}: {
- selectedColor: string;
- onColorSelect: (color: string) => void;
-}) {
- return (
- <div className="w-full h-full">
- <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
- <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
- <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
- <PipetteIcon className="size-4" />
- </div>
- {colors.map((color) => (
- <ColorItem
- key={color}
- color={color}
- isSelected={color === selectedColor}
- onClick={() => onColorSelect(color)}
- />
- ))}
- </div>
- </div>
- );
-}
-
-function ColorItem({
- color,
- isSelected,
- onClick,
-}: {
- color: string;
- isSelected: boolean;
- onClick: () => void;
-}) {
- return (
- <div
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
- isSelected && "border-2 border-primary"
- )}
- style={{ backgroundColor: color }}
- onClick={onClick}
- />
- );
-}
-
-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 (
- <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
- {blurLevels.map((blur) => (
- <div
- key={blur.value}
- className={cn(
- "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
- selectedBlur === blur.value && "border-2 border-primary"
- )}
- onClick={() => onBlurSelect(blur.value)}
- >
- <Image
- src={blurImage}
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- />
- <div className="absolute bottom-1 left-1 right-1 text-center">
- <span className="text-xs text-white bg-black/50 px-1 rounded">
- {blur.label}
- </span>
- </div>
- </div>
- ))}
- </div>
- );
-}
+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 ( + <Popover> + <PopoverTrigger asChild> + <Button + variant="text" + size="icon" + className="!size-4 border border-muted-foreground" + > + <BackgroundIcon className="!size-3" /> + </Button> + </PopoverTrigger> + <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0"> + <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3"> + <h2 className="text-sm">Background</h2> + <div className="flex items-center gap-2 text-sm"> + {tabs.map((tab) => ( + <span + key={tab.value} + onClick={() => { + // 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} + </span> + ))} + </div> + </div> + {activeTab === "color" ? ( + <ColorView + selectedColor={activeProject?.backgroundColor || "#000000"} + onColorSelect={handleColorSelect} + /> + ) : ( + <BlurView + selectedBlur={activeProject?.blurIntensity || 8} + onBlurSelect={handleBlurSelect} + /> + )} + </PopoverContent> + </Popover> + ); +} + +function ColorView({ + selectedColor, + onColorSelect, +}: { + selectedColor: string; + onColorSelect: (color: string) => void; +}) { + return ( + <div className="w-full h-full"> + <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div> + <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto"> + <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center"> + <PipetteIcon className="size-4" /> + </div> + {colors.map((color) => ( + <ColorItem + key={color} + color={color} + isSelected={color === selectedColor} + onClick={() => onColorSelect(color)} + /> + ))} + </div> + </div> + ); +} + +function ColorItem({ + color, + isSelected, + onClick, +}: { + color: string; + isSelected: boolean; + onClick: () => void; +}) { + return ( + <div + className={cn( + "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary", + isSelected && "border-2 border-primary", + )} + style={{ backgroundColor: color }} + onClick={onClick} + /> + ); +} + +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 ( + <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0"> + {blurLevels.map((blur) => ( + <div + key={blur.value} + className={cn( + "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden", + selectedBlur === blur.value && "border-2 border-primary", + )} + onClick={() => onBlurSelect(blur.value)} + > + <Image + src={blurImage} + alt={`Blur preview ${blur.label}`} + fill + className="object-cover" + style={{ filter: `blur(${blur.value}px)` }} + /> + <div className="absolute bottom-1 left-1 right-1 text-center"> + <span className="text-xs text-white bg-black/50 px-1 rounded"> + {blur.label} + </span> + </div> + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx index e2b32dda..f16eacf4 100644 --- a/apps/web/src/components/delete-project-dialog.tsx +++ b/apps/web/src/components/delete-project-dialog.tsx @@ -1,68 +1,68 @@ import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; export function DeleteProjectDialog({ - isOpen, - onOpenChange, - onConfirm, - projectName, + isOpen, + onOpenChange, + onConfirm, + projectName, }: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - projectName?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + projectName?: string; }) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent - onOpenAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <DialogHeader> - <DialogTitle> - {projectName ? ( - <> - {"Delete '"} - <span className="inline-block max-w-[300px] truncate align-bottom"> - {projectName} - </span> - {"'?"} - </> - ) : ( - "Delete Project?" - )} - </DialogTitle> - <DialogDescription> - Are you sure you want to delete this project? This action cannot be - undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button - variant="outline" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onOpenChange(false); - }} - > - Cancel - </Button> - <Button variant="destructive" onClick={onConfirm}> - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent + onOpenAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DialogHeader> + <DialogTitle> + {projectName ? ( + <> + {"Delete '"} + <span className="inline-block max-w-[300px] truncate align-bottom"> + {projectName} + </span> + {"'?"} + </> + ) : ( + "Delete Project?" + )} + </DialogTitle> + <DialogDescription> + Are you sure you want to delete this project? This action cannot be + undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} + > + Cancel + </Button> + <Button variant="destructive" onClick={onConfirm}> + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); } diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 3b51e182..99b9d8e3 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,68 +1,68 @@ -"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";
-
-export function EditorHeader() {
- const { getTotalDuration } = useTimelineStore();
- const { activeProject } = useProjectStore();
-
- const handleExport = () => {
- // TODO: Implement export functionality
- console.log("Export project");
- };
-
- const leftContent = (
- <div className="flex items-center gap-2">
- <Link
- href="/projects"
- className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
- >
- <ChevronLeft className="h-4 w-4" />
- <span className="text-sm">{activeProject?.name}</span>
- </Link>
- </div>
- );
-
- const centerContent = (
- <div className="flex items-center gap-2 text-xs">
- <span>
- {formatTimeCode(
- getTotalDuration(),
- "HH:MM:SS:FF",
- activeProject?.fps || 30
- )}
- </span>
- </div>
- );
-
- const rightContent = (
- <nav className="flex items-center gap-2">
- <KeyboardShortcutsHelp />
- <Button
- size="sm"
- variant="primary"
- className="h-7 text-xs"
- onClick={handleExport}
- >
- <Download className="h-4 w-4" />
- <span className="text-sm">Export</span>
- </Button>
- </nav>
- );
-
- return (
- <HeaderBase
- leftContent={leftContent}
- centerContent={centerContent}
- rightContent={rightContent}
- className="bg-background h-[3.2rem] px-4"
- />
- );
-}
+"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"; + +export function EditorHeader() { + const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); + + const handleExport = () => { + // TODO: Implement export functionality + console.log("Export project"); + }; + + const leftContent = ( + <div className="flex items-center gap-2"> + <Link + href="/projects" + className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity" + > + <ChevronLeft className="h-4 w-4" /> + <span className="text-sm">{activeProject?.name}</span> + </Link> + </div> + ); + + const centerContent = ( + <div className="flex items-center gap-2 text-xs"> + <span> + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30, + )} + </span> + </div> + ); + + const rightContent = ( + <nav className="flex items-center gap-2"> + <KeyboardShortcutsHelp /> + <Button + size="sm" + variant="primary" + className="h-7 text-xs" + onClick={handleExport} + > + <Download className="h-4 w-4" /> + <span className="text-sm">Export</span> + </Button> + </nav> + ); + + return ( + <HeaderBase + leftContent={leftContent} + centerContent={centerContent} + rightContent={rightContent} + className="bg-background h-[3.2rem] px-4" + /> + ); +} diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index 25d040db..c036b507 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 (
- <div className="h-screen w-screen flex items-center justify-center bg-background">
- <div className="flex flex-col items-center gap-4">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- <p className="text-sm text-muted-foreground">Loading editor...</p>
- </div>
- </div>
- );
- }
-
- // 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 ( + <div className="h-screen w-screen flex items-center justify-center bg-background"> + <div className="flex flex-col items-center gap-4"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <p className="text-sm text-muted-foreground">Loading editor...</p> + </div> + </div> + ); + } + + // 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..cae2359b 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -1,115 +1,117 @@ -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; - height?: number; - className?: string; + audioUrl: string; + height?: number; + className?: string; } -const AudioWaveform: React.FC<AudioWaveformProps> = ({ - audioUrl, - height = 32, - className = '' +const AudioWaveform: React.FC<AudioWaveformProps> = ({ + audioUrl, + height = 32, + className = "", }) => { - const waveformRef = useRef<HTMLDivElement>(null); - const wavesurfer = useRef<WaveSurfer | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const waveformRef = useRef<HTMLDivElement>(null); + const wavesurfer = useRef<WaveSurfer | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); - useEffect(() => { - let mounted = true; + useEffect(() => { + let mounted = true; - const initWaveSurfer = async () => { - if (!waveformRef.current || !audioUrl) return; + 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 - } - wavesurfer.current = null; - } + try { + // Clean up any existing instance + if (wavesurfer.current) { + try { + wavesurfer.current.destroy(); + } catch (e) { + // Silently ignore destroy errors + } + wavesurfer.current = null; + } - wavesurfer.current = WaveSurfer.create({ - container: waveformRef.current, - waveColor: 'rgba(255, 255, 255, 0.6)', - progressColor: 'rgba(255, 255, 255, 0.9)', - cursorColor: 'transparent', - barWidth: 2, - barGap: 1, - height: height, - normalize: true, - interact: false, - }); + wavesurfer.current = WaveSurfer.create({ + container: waveformRef.current, + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", + barWidth: 2, + barGap: 1, + height: height, + normalize: true, + interact: false, + }); - // Event listeners - wavesurfer.current.on('ready', () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); + // Event listeners + wavesurfer.current.on("ready", () => { + if (mounted) { + setIsLoading(false); + setError(false); + } + }); - wavesurfer.current.on('error', (err) => { - console.error('WaveSurfer error:', err); - if (mounted) { - setError(true); - setIsLoading(false); - } - }); + wavesurfer.current.on("error", (err) => { + console.error("WaveSurfer error:", err); + if (mounted) { + setError(true); + setIsLoading(false); + } + }); - await wavesurfer.current.load(audioUrl); + await wavesurfer.current.load(audioUrl); + } catch (err) { + console.error("Failed to initialize WaveSurfer:", err); + if (mounted) { + setError(true); + setIsLoading(false); + } + } + }; - } catch (err) { - console.error('Failed to initialize WaveSurfer:', err); - if (mounted) { - setError(true); - setIsLoading(false); - } - } - }; + initWaveSurfer(); - initWaveSurfer(); + return () => { + mounted = false; + if (wavesurfer.current) { + try { + wavesurfer.current.destroy(); + } catch (e) { + // Silently ignore destroy errors + } + wavesurfer.current = null; + } + }; + }, [audioUrl, height]); - return () => { - mounted = false; - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } - wavesurfer.current = null; - } - }; - }, [audioUrl, height]); + if (error) { + return ( + <div + className={`flex items-center justify-center ${className}`} + style={{ height }} + > + <span className="text-xs text-foreground/60">Audio unavailable</span> + </div> + ); + } - if (error) { - return ( - <div className={`flex items-center justify-center ${className}`} style={{ height }}> - <span className="text-xs text-foreground/60">Audio unavailable</span> - </div> - ); - } - - return ( - <div className={`relative ${className}`}> - {isLoading && ( - <div className="absolute inset-0 flex items-center justify-center"> - <span className="text-xs text-foreground/60">Loading...</span> - </div> - )} - <div - ref={waveformRef} - className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`} - style={{ height }} - /> - </div> - ); + return ( + <div className={`relative ${className}`}> + {isLoading && ( + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-xs text-foreground/60">Loading...</span> + </div> + )} + <div + ref={waveformRef} + className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`} + style={{ height }} + /> + </div> + ); }; -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..0c75d146 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 (
- <div
- className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
- onClick={handleClick}
- >
- <div className="flex items-center justify-center">
- <Upload className="h-10 w-10 text-foreground" />
- </div>
-
- <div className="space-y-2">
- <p className="text-xs text-muted-foreground max-w-sm">
- {isProcessing
- ? `Processing your files (${progress}%)`
- : "Drag and drop videos, photos, and audio files here"}
- </p>
- </div>
-
- {isProcessing && (
- <div className="w-full max-w-xs">
- <div className="w-full bg-muted/50 rounded-full h-2">
- <div
- className="bg-primary h-2 rounded-full transition-all duration-300"
- style={{ width: `${progress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- );
-}
+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 ( + <div + className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8" + onClick={handleClick} + > + <div className="flex items-center justify-center"> + <Upload className="h-10 w-10 text-foreground" /> + </div> + + <div className="space-y-2"> + <p className="text-xs text-muted-foreground max-w-sm"> + {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} + </p> + </div> + + {isProcessing && ( + <div className="w-full max-w-xs"> + <div className="w-full bg-muted/50 rounded-full h-2"> + <div + className="bg-primary h-2 rounded-full transition-all duration-300" + style={{ width: `${progress}%` }} + /> + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 9f7322c7..50fee601 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -1,55 +1,55 @@ -"use client";
-
-import { TabBar } from "./tabbar";
-import { MediaView } from "./views/media";
-import { useMediaPanelStore, Tab } from "./store";
-import { TextView } from "./views/text";
-
-export function MediaPanel() {
- const { activeTab } = useMediaPanelStore();
-
- const viewMap: Record<Tab, React.ReactNode> = {
- media: <MediaView />,
- audio: (
- <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
- ),
- text: <TextView />,
- stickers: (
- <div className="p-4 text-muted-foreground">
- Stickers view coming soon...
- </div>
- ),
- effects: (
- <div className="p-4 text-muted-foreground">
- Effects view coming soon...
- </div>
- ),
- transitions: (
- <div className="p-4 text-muted-foreground">
- Transitions view coming soon...
- </div>
- ),
- captions: (
- <div className="p-4 text-muted-foreground">
- Captions view coming soon...
- </div>
- ),
- filters: (
- <div className="p-4 text-muted-foreground">
- Filters view coming soon...
- </div>
- ),
- adjustment: (
- <div className="p-4 text-muted-foreground">
- Adjustment view coming soon...
- </div>
- ),
- };
-
- return (
- <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
- <TabBar />
- <div className="flex-1">{viewMap[activeTab]}</div>
- </div>
- );
-}
+"use client"; + +import { TabBar } from "./tabbar"; +import { MediaView } from "./views/media"; +import { useMediaPanelStore, Tab } from "./store"; +import { TextView } from "./views/text"; + +export function MediaPanel() { + const { activeTab } = useMediaPanelStore(); + + const viewMap: Record<Tab, React.ReactNode> = { + media: <MediaView />, + audio: ( + <div className="p-4 text-muted-foreground">Audio view coming soon...</div> + ), + text: <TextView />, + stickers: ( + <div className="p-4 text-muted-foreground"> + Stickers view coming soon... + </div> + ), + effects: ( + <div className="p-4 text-muted-foreground"> + Effects view coming soon... + </div> + ), + transitions: ( + <div className="p-4 text-muted-foreground"> + Transitions view coming soon... + </div> + ), + captions: ( + <div className="p-4 text-muted-foreground"> + Captions view coming soon... + </div> + ), + filters: ( + <div className="p-4 text-muted-foreground"> + Filters view coming soon... + </div> + ), + adjustment: ( + <div className="p-4 text-muted-foreground"> + Adjustment view coming soon... + </div> + ), + }; + + return ( + <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden"> + <TabBar /> + <div className="flex-1">{viewMap[activeTab]}</div> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 9668f285..0150c39a 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<MediaPanelStore>((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<MediaPanelStore>((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..50793243 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<HTMLDivElement>(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 (
- <div className="flex">
- <ScrollButton
- direction="left"
- onClick={scrollToStart}
- isVisible={!isAtStart}
- />
- <div
- ref={scrollContainerRef}
- className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
- >
- {(Object.keys(tabs) as Tab[]).map((tabKey) => {
- const tab = tabs[tabKey];
- return (
- <div
- className={cn(
- "flex flex-col gap-0.5 items-center cursor-pointer",
- activeTab === tabKey ? "text-primary" : "text-muted-foreground"
- )}
- onClick={() => setActiveTab(tabKey)}
- key={tabKey}
- >
- <tab.icon className="!size-[1.1rem]" />
- <span className="text-[0.65rem]">{tab.label}</span>
- </div>
- );
- })}
- </div>
- <ScrollButton
- direction="right"
- onClick={scrollToEnd}
- isVisible={!isAtEnd}
- />
- </div>
- );
-}
-
-function ScrollButton({
- direction,
- onClick,
- isVisible,
-}: {
- direction: "left" | "right";
- onClick: () => void;
- isVisible: boolean;
-}) {
- if (!isVisible) return null;
-
- const Icon = direction === "left" ? ChevronLeft : ChevronRight;
-
- return (
- <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
- <Button
- size="icon"
- className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
- onClick={onClick}
- >
- <Icon className="!size-4 text-foreground" />
- </Button>
- </div>
- );
-}
+"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<HTMLDivElement>(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 ( + <div className="flex"> + <ScrollButton + direction="left" + onClick={scrollToStart} + isVisible={!isAtStart} + /> + <div + ref={scrollContainerRef} + className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" + > + {(Object.keys(tabs) as Tab[]).map((tabKey) => { + const tab = tabs[tabKey]; + return ( + <div + className={cn( + "flex flex-col gap-0.5 items-center cursor-pointer", + activeTab === tabKey ? "text-primary" : "text-muted-foreground", + )} + onClick={() => setActiveTab(tabKey)} + key={tabKey} + > + <tab.icon className="!size-[1.1rem]" /> + <span className="text-[0.65rem]">{tab.label}</span> + </div> + ); + })} + </div> + <ScrollButton + direction="right" + onClick={scrollToEnd} + isVisible={!isAtEnd} + /> + </div> + ); +} + +function ScrollButton({ + direction, + onClick, + isVisible, +}: { + direction: "left" | "right"; + onClick: () => void; + isVisible: boolean; +}) { + if (!isVisible) return null; + + const Icon = direction === "left" ? ChevronLeft : ChevronRight; + + return ( + <div className="bg-panel-accent w-12 h-full flex items-center justify-center"> + <Button + size="icon" + className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10" + onClick={onClick} + > + <Icon className="!size-4 text-foreground" /> + </Button> + </div> + ); +} 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 631fe39a..712dd922 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<HTMLInputElement>(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<HTMLInputElement>) => {
- // 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 (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={item.url}
- alt={item.name}
- className="max-w-full max-h-full object-contain"
- loading="lazy"
- />
- </div>
- );
- }
-
- if (item.type === "video") {
- if (item.thumbnailUrl) {
- return (
- <div className="relative w-full h-full">
- <img
- src={item.thumbnailUrl}
- alt={item.name}
- className="w-full h-full object-cover rounded"
- loading="lazy"
- />
- <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
- <Video className="h-6 w-6 text-white drop-shadow-md" />
- </div>
- {item.duration && (
- <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
- {formatDuration(item.duration)}
- </div>
- )}
- </div>
- );
- }
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Video className="h-6 w-6 mb-1" />
- <span className="text-xs">Video</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- if (item.type === "audio") {
- return (
- <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
- <Music className="h-6 w-6 mb-1" />
- <span className="text-xs">Audio</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Image className="h-6 w-6" />
- <span className="text-xs mt-1">Unknown</span>
- </div>
- );
- };
-
- return (
- <>
- {/* Hidden file input for uploading media */}
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*,video/*,audio/*"
- multiple
- className="hidden"
- onChange={handleFileChange}
- />
-
- <div
- className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="p-3 pb-2">
- {/* Search and filter controls */}
- <div className="flex gap-2">
- <Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-full text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent className="">
- <SelectItem value="all">All</SelectItem>
- <SelectItem value="video">Video</SelectItem>
- <SelectItem value="audio">Audio</SelectItem>
- <SelectItem value="image">Image</SelectItem>
- </SelectContent>
- </Select>
- <Input
- type="text"
- placeholder="Search media..."
- className="min-w-[60px] flex-1 h-full text-xs"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- <Button
- variant="outline"
- size="lg"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
- >
- {isProcessing ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- <Plus className="h-4 w-4" />
- )}
- </Button>
- </div>
- </div>
-
- <div className="flex-1 overflow-y-auto p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={handleFileSelect}
- isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
- />
- ) : (
- <div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
- >
- {/* Render each media item as a draggable button */}
- {filteredMediaItems.map((item) => (
- <ContextMenu key={item.id}>
- <ContextMenuTrigger>
- <DraggableMediaItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: item.type,
- name: item.name,
- }}
- showPlusOnDrag={false}
- onAddToTimeline={(currentTime) =>
- useTimelineStore
- .getState()
- .addMediaAtTime(item, currentTime)
- }
- rounded={false}
- />
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(e) => handleRemove(e, item.id)}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </div>
- )}
- </div>
- </div>
- </>
- );
-}
+"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<HTMLInputElement>(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<HTMLInputElement>) => { + // 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 ( + <div className="w-full h-full flex items-center justify-center"> + <img + src={item.url} + alt={item.name} + className="max-w-full max-h-full object-contain" + loading="lazy" + /> + </div> + ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( + <div className="relative w-full h-full"> + <img + src={item.thumbnailUrl} + alt={item.name} + className="w-full h-full object-cover rounded" + loading="lazy" + /> + <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> + <Video className="h-6 w-6 text-white drop-shadow-md" /> + </div> + {item.duration && ( + <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + {formatDuration(item.duration)} + </div> + )} + </div> + ); + } + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Video className="h-6 w-6 mb-1" /> + <span className="text-xs">Video</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + if (item.type === "audio") { + return ( + <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> + <Music className="h-6 w-6 mb-1" /> + <span className="text-xs">Audio</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Image className="h-6 w-6" /> + <span className="text-xs mt-1">Unknown</span> + </div> + ); + }; + + return ( + <> + {/* Hidden file input for uploading media */} + <input + ref={fileInputRef} + type="file" + accept="image/*,video/*,audio/*" + multiple + className="hidden" + onChange={handleFileChange} + /> + + <div + className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} + {...dragProps} + > + <div className="p-3 pb-2"> + {/* Search and filter controls */} + <div className="flex gap-2"> + <Select value={mediaFilter} onValueChange={setMediaFilter}> + <SelectTrigger className="w-[80px] h-full text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent className=""> + <SelectItem value="all">All</SelectItem> + <SelectItem value="video">Video</SelectItem> + <SelectItem value="audio">Audio</SelectItem> + <SelectItem value="image">Image</SelectItem> + </SelectContent> + </Select> + <Input + type="text" + placeholder="Search media..." + className="min-w-[60px] flex-1 h-full text-xs" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + <Button + variant="outline" + size="lg" + onClick={handleFileSelect} + disabled={isProcessing} + className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center" + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Plus className="h-4 w-4" /> + )} + </Button> + </div> + </div> + + <div className="flex-1 overflow-y-auto p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={handleFileSelect} + isEmptyState={filteredMediaItems.length === 0 && !isDragOver} + /> + ) : ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + <ContextMenu key={item.id}> + <ContextMenuTrigger> + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => handleRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </div> + )} + </div> + </div> + </> + ); +} 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..b47edd8d 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 (
- <div className="p-4">
- <DraggableMediaItem
- name="Default text"
- preview={
- <div className="flex items-center justify-center w-full h-full bg-accent rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: textData.id,
- type: textData.type,
- name: textData.name,
- content: textData.content,
- }}
- aspectRatio={1}
- onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addTextAtTime(textData, currentTime)
- }
- showLabel={false}
- />
- </div>
- );
-}
+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 ( + <div className="p-4"> + <DraggableMediaItem + name="Default text" + preview={ + <div className="flex items-center justify-center w-full h-full bg-accent rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, + }} + aspectRatio={1} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } + showLabel={false} + /> + </div> + ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2ecee391..ea66de74 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -10,11 +10,11 @@ import { VideoPlayer } from "@/components/ui/video-player"; import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Play, Pause, Expand } from "lucide-react"; import { useState, useRef, useEffect } from "react"; @@ -25,473 +25,473 @@ import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; interface ActiveElement { - element: TimelineElement; - track: TimelineTrack; - mediaItem: MediaItem | null; + element: TimelineElement; + track: TimelineTrack; + mediaItem: MediaItem | null; } export function PreviewPanel() { - const { tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); - const { currentTime } = usePlaybackStore(); - const { canvasSize } = useEditorStore(); - const previewRef = useRef<HTMLDivElement>(null); - const containerRef = useRef<HTMLDivElement>(null); - const [previewDimensions, setPreviewDimensions] = useState({ - width: 0, - height: 0, - }); - const { activeProject } = useProjectStore(); + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const { currentTime } = usePlaybackStore(); + const { canvasSize } = useEditorStore(); + const previewRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [previewDimensions, setPreviewDimensions] = useState({ + width: 0, + height: 0, + }); + const { activeProject } = useProjectStore(); - // Calculate optimal preview size that fits in container while maintaining aspect ratio - useEffect(() => { - const updatePreviewSize = () => { - if (!containerRef.current) return; + // Calculate optimal preview size that fits in container while maintaining aspect ratio + useEffect(() => { + const updatePreviewSize = () => { + if (!containerRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); + const container = containerRef.current.getBoundingClientRect(); + const computedStyle = getComputedStyle(containerRef.current); - // Get padding values - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); + // Get padding values + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); - // Get gap value (gap-4 = 1rem = 16px) - const gap = parseFloat(computedStyle.gap) || 16; + // Get gap value (gap-4 = 1rem = 16px) + const gap = parseFloat(computedStyle.gap) || 16; - // Get toolbar height if it exists - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; + // Get toolbar height if it exists + const toolbar = containerRef.current.querySelector("[data-toolbar]"); + const toolbarHeight = toolbar + ? toolbar.getBoundingClientRect().height + : 0; - // Calculate available space after accounting for padding, gap, and toolbar - const availableWidth = container.width - paddingLeft - paddingRight; - const availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 0); + // Calculate available space after accounting for padding, gap, and toolbar + const availableWidth = container.width - paddingLeft - paddingRight; + const availableHeight = + container.height - + paddingTop - + paddingBottom - + toolbarHeight - + (toolbarHeight > 0 ? gap : 0); - const targetRatio = canvasSize.width / canvasSize.height; - const containerRatio = availableWidth / availableHeight; + const targetRatio = canvasSize.width / canvasSize.height; + const containerRatio = availableWidth / availableHeight; - let width, height; + let width, height; - if (containerRatio > targetRatio) { - // Container is wider - constrain by height - height = availableHeight; - width = height * targetRatio; - } else { - // Container is taller - constrain by width - width = availableWidth; - height = width / targetRatio; - } + if (containerRatio > targetRatio) { + // Container is wider - constrain by height + height = availableHeight; + width = height * targetRatio; + } else { + // Container is taller - constrain by width + width = availableWidth; + height = width / targetRatio; + } - setPreviewDimensions({ width, height }); - }; + setPreviewDimensions({ width, height }); + }; - updatePreviewSize(); + updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } + const resizeObserver = new ResizeObserver(updatePreviewSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } - return () => resizeObserver.disconnect(); - }, [canvasSize.width, canvasSize.height]); + return () => resizeObserver.disconnect(); + }, [canvasSize.width, canvasSize.height]); - // Get active elements at current time - const getActiveElements = (): ActiveElement[] => { - const activeElements: ActiveElement[] = []; + // Get active elements at current time + const getActiveElements = (): ActiveElement[] => { + const activeElements: ActiveElement[] = []; - tracks.forEach((track) => { - track.elements.forEach((element) => { - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + tracks.forEach((track) => { + track.elements.forEach((element) => { + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime >= elementStart && currentTime < elementEnd) { - let mediaItem = null; + if (currentTime >= elementStart && currentTime < elementEnd) { + let mediaItem = null; - // Only get media item for media elements - if (element.type === "media") { - mediaItem = - element.mediaId === "test" - ? null // Test elements don't have a real media item - : mediaItems.find((item) => item.id === element.mediaId) || - null; - } + // Only get media item for media elements + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null // Test elements don't have a real media item + : mediaItems.find((item) => item.id === element.mediaId) || + null; + } - activeElements.push({ element, track, mediaItem }); - } - }); - }); + activeElements.push({ element, track, mediaItem }); + } + }); + }); - return activeElements; - }; + return activeElements; + }; - const activeElements = getActiveElements(); + const activeElements = getActiveElements(); - // Check if there are any elements in the timeline at all - const hasAnyElements = tracks.some((track) => track.elements.length > 0); + // Check if there are any elements in the timeline at all + const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Get media elements for blur background (video/image only) - const getBlurBackgroundElements = (): ActiveElement[] => { - return activeElements.filter( - ({ element, mediaItem }) => - element.type === "media" && - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") && - element.mediaId !== "test" // Exclude test elements - ); - }; + // Get media elements for blur background (video/image only) + const getBlurBackgroundElements = (): ActiveElement[] => { + return activeElements.filter( + ({ element, mediaItem }) => + element.type === "media" && + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") && + element.mediaId !== "test", // Exclude test elements + ); + }; - const blurBackgroundElements = getBlurBackgroundElements(); + const blurBackgroundElements = getBlurBackgroundElements(); - // Render blur background layer - const renderBlurBackground = () => { - if ( - !activeProject?.backgroundType || - activeProject.backgroundType !== "blur" || - blurBackgroundElements.length === 0 - ) { - return null; - } + // Render blur background layer + const renderBlurBackground = () => { + if ( + !activeProject?.backgroundType || + activeProject.backgroundType !== "blur" || + blurBackgroundElements.length === 0 + ) { + return null; + } - // Use the first media element for background (could be enhanced to use primary/focused element) - const backgroundElement = blurBackgroundElements[0]; - const { element, mediaItem } = backgroundElement; + // Use the first media element for background (could be enhanced to use primary/focused element) + const backgroundElement = blurBackgroundElements[0]; + const { element, mediaItem } = backgroundElement; - if (!mediaItem) return null; + if (!mediaItem) return null; - const blurIntensity = activeProject.blurIntensity || 8; + const blurIntensity = activeProject.blurIntensity || 8; - if (mediaItem.type === "video") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - className="w-full h-full object-cover" - /> - </div> - ); - } + if (mediaItem.type === "video") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + className="w-full h-full object-cover" + /> + </div> + ); + } - if (mediaItem.type === "image") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="w-full h-full object-cover" - draggable={false} - /> - </div> - ); - } + if (mediaItem.type === "image") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> + </div> + ); + } - return null; - }; + return null; + }; - // Render an element - const renderElement = (elementData: ActiveElement, index: number) => { - const { element, mediaItem } = elementData; + // Render an element + const renderElement = (elementData: ActiveElement, index: number) => { + const { element, mediaItem } = elementData; - // Text elements - if (element.type === "text") { - const fontClassName = - FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + // Text elements + if (element.type === "text") { + const fontClassName = + FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; - const scaleRatio = previewDimensions.width / canvasSize.width; + const scaleRatio = previewDimensions.width / canvasSize.width; - return ( - <div - key={element.id} - className="absolute flex items-center justify-center" - style={{ - left: `${50 + (element.x / canvasSize.width) * 100}%`, - top: `${50 + (element.y / canvasSize.height) * 100}%`, - transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, - opacity: element.opacity, - zIndex: 100 + index, // Text elements on top - }} - > - <div - className={fontClassName} - style={{ - fontSize: `${element.fontSize}px`, - color: element.color, - backgroundColor: element.backgroundColor, - textAlign: element.textAlign, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textDecoration: element.textDecoration, - padding: "4px 8px", - borderRadius: "2px", - whiteSpace: "nowrap", - // Fallback for system fonts that don't have classes - ...(fontClassName === "" && { fontFamily: element.fontFamily }), - }} - > - {element.content} - </div> - </div> - ); - } + return ( + <div + key={element.id} + className="absolute flex items-center justify-center" + style={{ + left: `${50 + (element.x / canvasSize.width) * 100}%`, + top: `${50 + (element.y / canvasSize.height) * 100}%`, + transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, + opacity: element.opacity, + zIndex: 100 + index, // Text elements on top + }} + > + <div + className={fontClassName} + style={{ + fontSize: `${element.fontSize}px`, + color: element.color, + backgroundColor: element.backgroundColor, + textAlign: element.textAlign, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textDecoration: element.textDecoration, + padding: "4px 8px", + borderRadius: "2px", + whiteSpace: "nowrap", + // Fallback for system fonts that don't have classes + ...(fontClassName === "" && { fontFamily: element.fontFamily }), + }} + > + {element.content} + </div> + </div> + ); + } - // Media elements - if (element.type === "media") { - // Test elements - if (!mediaItem || element.mediaId === "test") { - return ( - <div - key={element.id} - className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" - > - <div className="text-center"> - <div className="text-2xl mb-2">🎬</div> - <p className="text-xs text-white">{element.name}</p> - </div> - </div> - ); - } + // Media elements + if (element.type === "media") { + // Test elements + if (!mediaItem || element.mediaId === "test") { + return ( + <div + key={element.id} + className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎬</div> + <p className="text-xs text-white">{element.name}</p> + </div> + </div> + ); + } - // Video elements - if (mediaItem.type === "video") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - /> - </div> - ); - } + // Video elements + if (mediaItem.type === "video") { + return ( + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + /> + </div> + ); + } - // Image elements - if (mediaItem.type === "image") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="max-w-full max-h-full object-contain" - draggable={false} - /> - </div> - ); - } + // Image elements + if (mediaItem.type === "image") { + return ( + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="max-w-full max-h-full object-contain" + draggable={false} + /> + </div> + ); + } - // Audio elements (no visual representation) - if (mediaItem.type === "audio") { - return ( - <div key={element.id} className="absolute inset-0"> - <AudioPlayer - src={mediaItem.url!} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - trackMuted={elementData.track.muted} - /> - </div> - ); - } - } + // Audio elements (no visual representation) + if (mediaItem.type === "audio") { + return ( + <div key={element.id} className="absolute inset-0"> + <AudioPlayer + src={mediaItem.url!} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + trackMuted={elementData.track.muted} + /> + </div> + ); + } + } - return null; - }; + return null; + }; - return ( - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> - <div - ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" - > - <div className="flex-1"></div> - {hasAnyElements ? ( - <div - ref={previewRef} - className="relative overflow-hidden border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - backgroundColor: - activeProject?.backgroundType === "blur" - ? "transparent" - : activeProject?.backgroundColor || "#000000", - }} - > - {renderBlurBackground()} - {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, index) => - renderElement(elementData, index) - ) - )} - {/* Show message when blur is selected but no media available */} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background - </div> - )} - </div> - ) : null} + return ( + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div + ref={containerRef} + className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + > + <div className="flex-1"></div> + {hasAnyElements ? ( + <div + ref={previewRef} + className="relative overflow-hidden border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + }} + > + {renderBlurBackground()} + {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, index) => + renderElement(elementData, index), + ) + )} + {/* Show message when blur is selected but no media available */} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + ) : null} - <div className="flex-1"></div> + <div className="flex-1"></div> - <PreviewToolbar hasAnyElements={hasAnyElements} /> - </div> - </div> - ); + <PreviewToolbar hasAnyElements={hasAnyElements} /> + </div> + </div> + ); } function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { - const { isPlaying, toggle, currentTime } = usePlaybackStore(); - const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { getTotalDuration } = useTimelineStore(); - const { activeProject } = useProjectStore(); - const { - currentPreset, - isOriginal, - getOriginalAspectRatio, - getDisplayName, - canvasPresets, - } = useAspectRatio(); + const { isPlaying, toggle, currentTime } = usePlaybackStore(); + const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); + const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); + const { + currentPreset, + isOriginal, + getOriginalAspectRatio, + getDisplayName, + canvasPresets, + } = useAspectRatio(); - const handlePresetSelect = (preset: { width: number; height: number }) => { - setCanvasSize({ width: preset.width, height: preset.height }); - }; + const handlePresetSelect = (preset: { width: number; height: number }) => { + setCanvasSize({ width: preset.width, height: preset.height }); + }; - const handleOriginalSelect = () => { - const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeToOriginal(aspectRatio); - }; + const handleOriginalSelect = () => { + const aspectRatio = getOriginalAspectRatio(); + setCanvasSizeToOriginal(aspectRatio); + }; - return ( - <div - data-toolbar - className="flex items-end justify-between gap-2 p-1 pt-2 w-full" - > - <div> - <p - className={cn( - "text-[0.75rem] text-muted-foreground flex items-center gap-1", - !hasAnyElements && "opacity-50" - )} - > - <span className="text-primary tabular-nums"> - {formatTimeCode( - currentTime, - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - </span> - <span className="opacity-50">/</span> - <span className="tabular-nums"> - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - </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-3"> - <BackgroundSettings /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - size="sm" - className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" - disabled={!hasAnyElements} - > - {getDisplayName()} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={handleOriginalSelect} - className={cn("text-xs", isOriginal && "font-semibold")} - > - Original - </DropdownMenuItem> - <DropdownMenuSeparator /> - {canvasPresets.map((preset) => ( - <DropdownMenuItem - key={preset.name} - onClick={() => handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - <Button - variant="text" - size="icon" - className="!size-4 text-muted-foreground" - > - <Expand className="!size-4" /> - </Button> - </div> - </div> - ); + return ( + <div + data-toolbar + className="flex items-end justify-between gap-2 p-1 pt-2 w-full" + > + <div> + <p + className={cn( + "text-[0.75rem] text-muted-foreground flex items-center gap-1", + !hasAnyElements && "opacity-50", + )} + > + <span className="text-primary tabular-nums"> + {formatTimeCode( + currentTime, + "HH:MM:SS:FF", + activeProject?.fps || 30, + )} + </span> + <span className="opacity-50">/</span> + <span className="tabular-nums"> + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30, + )} + </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-3"> + <BackgroundSettings /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + size="sm" + className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" + disabled={!hasAnyElements} + > + {getDisplayName()} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={handleOriginalSelect} + className={cn("text-xs", isOriginal && "font-semibold")} + > + Original + </DropdownMenuItem> + <DropdownMenuSeparator /> + {canvasPresets.map((preset) => ( + <DropdownMenuItem + key={preset.name} + onClick={() => handlePresetSelect(preset)} + className={cn( + "text-xs", + currentPreset?.name === preset.name && "font-semibold", + )} + > + {preset.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + <Button + variant="text" + size="icon" + className="!size-4 text-muted-foreground" + > + <Expand className="!size-4" /> + </Button> + </div> + </div> + ); } diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx index 384f0c5e..5e2a781f 100644 --- a/apps/web/src/components/editor/properties-panel/audio-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/audio-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline";
-
-export function AudioProperties({ element }: { element: MediaElement }) {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
\ No newline at end of file +import { MediaElement } from "@/types/timeline"; + +export function AudioProperties({ element }: { element: MediaElement }) { + return <div className="space-y-4 p-5">Audio properties</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 9e28850f..7d2845be 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -10,100 +10,100 @@ import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "../../ui/select"; import { FPS_PRESETS } from "@/constants/timeline-constants"; export function PropertiesPanel() { - const { activeProject, updateProjectFps } = useProjectStore(); - const { getDisplayName, canvasSize } = useAspectRatio(); - const { selectedElements, tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { activeProject, updateProjectFps } = useProjectStore(); + const { getDisplayName, canvasSize } = useAspectRatio(); + const { selectedElements, tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - if (!isNaN(fps) && fps > 0) { - updateProjectFps(fps); - } - }; + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + if (!isNaN(fps) && fps > 0) { + updateProjectFps(fps); + } + }; - const emptyView = ( - <div className="space-y-4 p-5"> - {/* Media Properties */} - <div className="flex flex-col gap-3"> - <PropertyItem label="Name:" value={activeProject?.name || ""} /> - <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> - <PropertyItem - label="Resolution:" - value={`${canvasSize.width} × ${canvasSize.height}`} - /> - <div className="flex justify-between items-center"> - <Label className="text-xs text-muted-foreground">Frame rate:</Label> - <Select - value={(activeProject?.fps || 30).toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger className="w-32 h-6 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map(({ value, label }) => ( - <SelectItem key={value} value={value} className="text-xs"> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - </div> - ); + const emptyView = ( + <div className="space-y-4 p-5"> + {/* Media Properties */} + <div className="flex flex-col gap-3"> + <PropertyItem label="Name:" value={activeProject?.name || ""} /> + <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> + <PropertyItem + label="Resolution:" + value={`${canvasSize.width} × ${canvasSize.height}`} + /> + <div className="flex justify-between items-center"> + <Label className="text-xs text-muted-foreground">Frame rate:</Label> + <Select + value={(activeProject?.fps || 30).toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-32 h-6 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map(({ value, label }) => ( + <SelectItem key={value} value={value} className="text-xs"> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + ); - return ( - <ScrollArea className="h-full bg-panel rounded-sm"> - {selectedElements.length > 0 - ? selectedElements.map(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + return ( + <ScrollArea className="h-full bg-panel rounded-sm"> + {selectedElements.length > 0 + ? selectedElements.map(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (element?.type === "text") { - return ( - <div key={elementId}> - <TextProperties element={element} trackId={trackId} /> - </div> - ); - } - if (element?.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId - ); + if (element?.type === "text") { + return ( + <div key={elementId}> + <TextProperties element={element} trackId={trackId} /> + </div> + ); + } + if (element?.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId, + ); - if (mediaItem?.type === "audio") { - return <AudioProperties element={element} />; - } + if (mediaItem?.type === "audio") { + return <AudioProperties element={element} />; + } - return ( - <div key={elementId}> - <MediaProperties element={element} /> - </div> - ); - } - return null; - }) - : emptyView} - </ScrollArea> - ); + return ( + <div key={elementId}> + <MediaProperties element={element} /> + </div> + ); + } + return null; + }) + : emptyView} + </ScrollArea> + ); } function PropertyItem({ label, value }: { label: string; value: string }) { - return ( - <div className="flex justify-between"> - <Label className="text-xs text-muted-foreground">{label}</Label> - <span className="text-xs text-right">{value}</span> - </div> - ); + return ( + <div className="flex justify-between"> + <Label className="text-xs text-muted-foreground">{label}</Label> + <span className="text-xs text-right">{value}</span> + </div> + ); } 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..fe2d448e 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 <div className="space-y-4 p-5">Media properties</div>;
-}
+import { MediaElement } from "@/types/timeline"; + +export function MediaProperties({ element }: { element: MediaElement }) { + return <div className="space-y-4 p-5">Media properties</div>; +} 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..30f1b40c 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 (
- <div
- className={cn(
- "flex gap-2",
- direction === "row"
- ? "items-center justify-between gap-6"
- : "flex-col gap-1",
- className
- )}
- >
- {children}
- </div>
- );
-}
-
-export function PropertyItemLabel({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <label className={cn("text-xs", className)}>{children}</label>;
-}
-
-export function PropertyItemValue({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <div className={cn("flex-1", className)}>{children}</div>;
-}
+import { cn } from "@/lib/utils"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( + <div + className={cn( + "flex gap-2", + direction === "row" + ? "items-center justify-between gap-6" + : "flex-col gap-1", + className, + )} + > + {children} + </div> + ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <label className={cn("text-xs", className)}>{children}</label>; +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cn("flex-1", className)}>{children}</div>; +} 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..4d58752b 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 (
- <div className="space-y-6 p-5">
- <Textarea
- placeholder="Name"
- defaultValue={element.content}
- className="min-h-[4.5rem] resize-none bg-background/50"
- onChange={(e) =>
- updateTextElement(trackId, element.id, { content: e.target.value })
- }
- />
- <PropertyItem direction="row">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
- updateTextElement(trackId, element.id, { fontFamily: value })
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- defaultValue={[element.fontSize]}
- min={8}
- max={300}
- step={1}
- onValueChange={([value]) =>
- updateTextElement(trackId, element.id, { fontSize: value })
- }
- className="w-full"
- />
- <Input
- type="number"
- value={element.fontSize}
- onChange={(e) =>
- updateTextElement(trackId, element.id, {
- fontSize: parseInt(e.target.value),
- })
- }
- className="w-12 !text-xs h-7 rounded-sm text-center
- [appearance:textfield]
- [&::-webkit-outer-spin-button]:appearance-none
- [&::-webkit-inner-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- );
-}
+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 ( + <div className="space-y-6 p-5"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="min-h-[4.5rem] resize-none bg-background/50" + onChange={(e) => + updateTextElement(trackId, element.id, { content: e.target.value }) + } + /> + <PropertyItem direction="row"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + updateTextElement(trackId, element.id, { fontFamily: value }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + defaultValue={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => + updateTextElement(trackId, element.id, { fontSize: value }) + } + className="w-full" + /> + <Input + type="number" + value={element.fontSize} + onChange={(e) => + updateTextElement(trackId, element.id, { + fontSize: parseInt(e.target.value), + }) + } + className="w-12 !text-xs h-7 rounded-sm text-center + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index 5c452f7c..69b753f4 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -1,58 +1,58 @@ -"use client";
-
-import { useEffect, useRef } from "react";
-
-interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement>;
- isActive: boolean;
-}
-
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) return;
-
- const container = containerRef.current;
- const containerRect = container.getBoundingClientRect();
-
- // Calculate relative positions within the container
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- // Calculate the selection rectangle bounds
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- // Update the selection box position and size
- if (selectionBoxRef.current) {
- selectionBoxRef.current.style.left = `${left}px`;
- selectionBoxRef.current.style.top = `${top}px`;
- selectionBoxRef.current.style.width = `${width}px`;
- selectionBoxRef.current.style.height = `${height}px`;
- }
- }, [startPos, currentPos, isActive, containerRef]);
-
- if (!isActive || !startPos || !currentPos) return null;
-
- return (
- <div
- ref={selectionBoxRef}
- className="absolute pointer-events-none z-50"
- style={{
- backgroundColor: "hsl(var(--foreground) / 0.1)",
- }}
- />
- );
-}
+"use client"; + +import { useEffect, useRef } from "react"; + +interface SelectionBoxProps { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + containerRef: React.RefObject<HTMLElement>; + isActive: boolean; +} + +export function SelectionBox({ + startPos, + currentPos, + containerRef, + isActive, +}: SelectionBoxProps) { + const selectionBoxRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!isActive || !startPos || !currentPos || !containerRef.current) return; + + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + + // Calculate relative positions within the container + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + // Calculate the selection rectangle bounds + const left = Math.min(startX, currentX); + const top = Math.min(startY, currentY); + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + + // Update the selection box position and size + if (selectionBoxRef.current) { + selectionBoxRef.current.style.left = `${left}px`; + selectionBoxRef.current.style.top = `${top}px`; + selectionBoxRef.current.style.width = `${width}px`; + selectionBoxRef.current.style.height = `${height}px`; + } + }, [startPos, currentPos, isActive, containerRef]); + + if (!isActive || !startPos || !currentPos) return null; + + return ( + <div + ref={selectionBoxRef} + className="absolute pointer-events-none z-50" + style={{ + backgroundColor: "hsl(var(--foreground) / 0.1)", + }} + /> + ); +} diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 83a1f678..2bbe605d 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -5,50 +5,50 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { - snapPoint: SnapPoint | null; - zoomLevel: number; - isVisible: boolean; - tracks: TimelineTrack[]; - timelineRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; } export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - tracks, - timelineRef, - trackLabelsRef, + snapPoint, + zoomLevel, + isVisible, + tracks, + timelineRef, + trackLabelsRef, }: SnapIndicatorProps) { - if (!isVisible || !snapPoint) { - return null; - } + if (!isVisible || !snapPoint) { + return null; + } - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; + // Get dynamic track labels width, fallback to 0 if no tracks or no ref + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; - const leftPosition = - trackLabelsWidth + - snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = + trackLabelsWidth + + snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - return ( - <div - className="absolute pointer-events-none z-[90]" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", - }} - > - <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> - </div> - ); + return ( + <div + className="absolute pointer-events-none z-[90]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", + }} + > + <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> + </div> + ); } diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx index 39281a9a..ba5a4cc6 100644 --- a/apps/web/src/components/editor/speed-control.tsx +++ b/apps/web/src/components/editor/speed-control.tsx @@ -4,43 +4,43 @@ import { Button } from "../ui/button"; import { usePlaybackStore } from "@/stores/playback-store"; const SPEED_PRESETS = [ - { label: "0.5x", value: 0.5 }, - { label: "1x", value: 1.0 }, - { label: "1.5x", value: 1.5 }, - { label: "2x", value: 2.0 }, + { label: "0.5x", value: 0.5 }, + { label: "1x", value: 1.0 }, + { label: "1.5x", value: 1.5 }, + { label: "2x", value: 2.0 }, ]; export function SpeedControl() { - const { speed, setSpeed } = usePlaybackStore(); + const { speed, setSpeed } = usePlaybackStore(); - return ( - <div className="space-y-4"> - <h3 className="text-sm font-medium">Playback Speed</h3> - <div className="space-y-4"> - <div className="flex gap-2"> - {SPEED_PRESETS.map((preset) => ( - <Button - key={preset.value} - variant={speed === preset.value ? "default" : "outline"} - className="flex-1" - onClick={() => setSpeed(preset.value)} - > - {preset.label} - </Button> - ))} - </div> - <div className="space-y-1"> - <Label>Custom ({speed.toFixed(1)}x)</Label> - <Slider - value={[speed]} - min={0.1} - max={2.0} - step={0.1} - onValueChange={(value) => setSpeed(value[0])} - className="mt-2" - /> - </div> - </div> - </div> - ); -}
\ No newline at end of file + return ( + <div className="space-y-4"> + <h3 className="text-sm font-medium">Playback Speed</h3> + <div className="space-y-4"> + <div className="flex gap-2"> + {SPEED_PRESETS.map((preset) => ( + <Button + key={preset.value} + variant={speed === preset.value ? "default" : "outline"} + className="flex-1" + onClick={() => setSpeed(preset.value)} + > + {preset.label} + </Button> + ))} + </div> + <div className="space-y-1"> + <Label>Custom ({speed.toFixed(1)}x)</Label> + <Slider + value={[speed]} + min={0.1} + max={2.0} + step={0.1} + onValueChange={(value) => setSpeed(value[0])} + className="mt-2" + /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index eb9f1c01..c0acef6a 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -3,16 +3,16 @@ import { useState } from "react"; import { Button } from "../ui/button"; import { - MoreVertical, - Scissors, - Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, - Copy, - RefreshCw, + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, + Type, + Copy, + RefreshCw, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -22,371 +22,371 @@ import { toast } from "sonner"; import { TimelineElementProps, TrackType } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { - getTrackElementClasses, - TIMELINE_CONSTANTS, - getTrackHeight, + getTrackElementClasses, + TIMELINE_CONSTANTS, + getTrackHeight, } from "@/constants/timeline-constants"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, } from "../ui/dropdown-menu"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, } from "../ui/context-menu"; export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, + element, + track, + zoomLevel, + isSelected, + onElementMouseDown, + onElementClick, }: TimelineElementProps) { - const { mediaItems } = useMediaStore(); - const { - updateElementTrim, - updateElementDuration, - removeElementFromTrack, - removeElementFromTrackWithRipple, - dragState, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - addElementToTrack, - replaceElementMedia, - rippleEditingEnabled, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); + const { mediaItems } = useMediaStore(); + const { + updateElementTrim, + updateElementDuration, + removeElementFromTrack, + removeElementFromTrackWithRipple, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + rippleEditingEnabled, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); - const [elementMenuOpen, setElementMenuOpen] = useState(false); + const [elementMenuOpen, setElementMenuOpen] = useState(false); - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); + const { + resizing, + isResizing, + handleResizeStart, + handleResizeMove, + handleResizeEnd, + } = useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - const elementWidth = Math.max( - TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, - effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel - ); + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max( + TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, + 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; + // 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; + // Element should always be positioned at startTime - trimStart only affects content, not position + const elementLeft = elementStartTime * 50 * zoomLevel; - const handleElementSplitContext = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const handleElementSplitContext = () => { + 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"); - } - }; + 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"); + } + }; - const handleElementDuplicateContext = () => { - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; + const handleElementDuplicateContext = () => { + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; - const handleElementDeleteContext = () => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(track.id, element.id); - } else { - removeElementFromTrack(track.id, element.id); - } - }; + const handleElementDeleteContext = () => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(track.id, element.id); + } else { + removeElementFromTrack(track.id, element.id); + } + }; - const handleReplaceClip = () => { - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } + const handleReplaceClip = () => { + 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; + // 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 success = await replaceElementMedia(track.id, element.id, file); - if (success) { - toast.success("Clip replaced successfully"); - } else { - toast.error("Failed to replace clip"); - } - } catch (error) { - toast.error("Failed to replace clip"); - console.log( - JSON.stringify({ error: "Failed to replace clip", details: error }) - ); - } - }; - input.click(); - }; + try { + const success = await replaceElementMedia(track.id, element.id, file); + if (success) { + toast.success("Clip replaced successfully"); + } else { + toast.error("Failed to replace clip"); + } + } catch (error) { + toast.error("Failed to replace clip"); + console.log( + JSON.stringify({ error: "Failed to replace clip", details: error }), + ); + } + }; + input.click(); + }; - const renderElementContent = () => { - if (element.type === "text") { - return ( - <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-foreground/80 truncate"> - {element.content} - </span> - </div> - ); - } + const renderElementContent = () => { + if (element.type === "text") { + return ( + <div className="w-full h-full flex items-center justify-start pl-2"> + <span className="text-xs text-foreground/80 truncate"> + {element.content} + </span> + </div> + ); + } - // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) { - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - } + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) { + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + } - const TILE_ASPECT_RATIO = 16 / 9; + const TILE_ASPECT_RATIO = 16 / 9; - if (mediaItem.type === "image") { - // Calculate tile size based on 16:9 aspect ratio - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled images */} - <div - className="absolute top-3 bottom-3 left-0 right-0" - style={{ - backgroundImage: mediaItem.url - ? `url(${mediaItem.url})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled background of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( + return ( + <div className="w-full h-full flex items-center justify-center"> + <div className="bg-[#004D52] py-3 w-full h-full relative"> + {/* Background with tiled images */} + <div + className="absolute top-3 bottom-3 left-0 right-0" + style={{ + backgroundImage: mediaItem.url + ? `url(${mediaItem.url})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled background of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( to right, transparent 0px, transparent ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth}px )`, - backgroundPosition: "left center", - }} - /> - </div> - </div> - ); - } + backgroundPosition: "left center", + }} + /> + </div> + </div> + ); + } - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - VIDEO_TILE_PADDING; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 h-full relative overflow-hidden"> - {/* Background with tiled thumbnails */} - <div - className="absolute inset-0" - style={{ - backgroundImage: mediaItem.thumbnailUrl - ? `url(${mediaItem.thumbnailUrl})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled thumbnail of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute inset-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 h-full relative overflow-hidden"> + {/* Background with tiled thumbnails */} + <div + className="absolute inset-0" + style={{ + backgroundImage: mediaItem.thumbnailUrl + ? `url(${mediaItem.thumbnailUrl})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled thumbnail of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute inset-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( to right, transparent 0px, transparent ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth}px )`, - backgroundPosition: "left center", - }} - /> - </div> - {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( - <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> - {element.name} - </div> - ) : ( - <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> - {element.name} - </span> - )} - </div> - ); - } + backgroundPosition: "left center", + }} + /> + </div> + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> + {element.name} + </div> + ) : ( + <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> + {element.name} + </span> + )} + </div> + ); + } - // Render audio element -> - if (mediaItem.type === "audio") { - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 min-w-0"> - <AudioWaveform - audioUrl={mediaItem.url || ""} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + // Render audio element -> + if (mediaItem.type === "audio") { + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 min-w-0"> + <AudioWaveform + audioUrl={mediaItem.url || ""} + height={24} + className="w-full" + /> + </div> + </div> + ); + } - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - }; + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + }; - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); + } + }; - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - data-element-id={element.id} - data-track-id={track.id} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} - > - <div - className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - }`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none timeline-element ${ + isBeingDragged ? "z-50" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + }} + data-element-id={element.id} + data-track-id={track.id} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} + > + <div + className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( + track.type, + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + isBeingDragged ? "z-50" : "z-10" + }`} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > + <div className="absolute inset-0 flex items-center h-full"> + {renderElementContent()} + </div> - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - /> - <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - /> - </> - )} - </div> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - Split at playhead - </ContextMenuItem> - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - {element.type === "media" && ( - <ContextMenuItem onClick={handleReplaceClip}> - <RefreshCw className="h-4 w-4 mr-2" /> - Replace clip - </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"} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); + {isSelected && ( + <> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} + /> + </> + )} + </div> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={handleElementSplitContext}> + <Scissors className="h-4 w-4 mr-2" /> + Split at playhead + </ContextMenuItem> + <ContextMenuItem onClick={handleElementDuplicateContext}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + {element.type === "media" && ( + <ContextMenuItem onClick={handleReplaceClip}> + <RefreshCw className="h-4 w-4 mr-2" /> + Replace clip + </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"} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); } diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index 7a357067..6e2e1cfa 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -1,116 +1,116 @@ -"use client";
-
-import { useRef } from "react";
-import { TimelineTrack } from "@/types/timeline";
-import {
- TIMELINE_CONSTANTS,
- getTotalTracksHeight,
-} from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
-
-interface TimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- trackLabelsRef?: React.RefObject<HTMLDivElement>;
- timelineRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
- isSnappingToPlayhead?: boolean;
-}
-
-export function TimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- tracks,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
-}: TimelinePlayheadProps) {
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- // Use timeline container height minus a few pixels for breathing room
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
-
- // Get dynamic track labels width, fallback to 0 if no tracks or no ref
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
- const leftPosition =
- trackLabelsWidth +
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
-
- return (
- <div
- ref={playheadRef}
- className="absolute pointer-events-auto z-[150]"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: "2px", // Slightly wider for better click target
- }}
- onMouseDown={handlePlayheadMouseDown}
- >
- {/* The playhead line spanning full height */}
- <div
- className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
- />
-
- {/* Playhead dot indicator at the top (in ruler area) */}
- <div
- className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
- />
- </div>
- );
-}
-
-// Also export a hook for getting ruler handlers
-export function useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- return { handleRulerMouseDown, isDraggingRuler };
-}
-
-export { TimelinePlayhead as default };
+"use client"; + +import { useRef } from "react"; +import { TimelineTrack } from "@/types/timeline"; +import { + TIMELINE_CONSTANTS, + getTotalTracksHeight, +} from "@/constants/timeline-constants"; +import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; + +interface TimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + seek: (time: number) => void; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + timelineRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + currentTime, + duration, + zoomLevel, + tracks, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + trackLabelsRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // Use timeline container height minus a few pixels for breathing room + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + + // Get dynamic track labels width, fallback to 0 if no tracks or no ref + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; + const leftPosition = + trackLabelsWidth + + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + return ( + <div + ref={playheadRef} + className="absolute pointer-events-auto z-[150]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", // Slightly wider for better click target + }} + onMouseDown={handlePlayheadMouseDown} + > + {/* The playhead line spanning full height */} + <div + className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} + /> + + {/* Playhead dot indicator at the top (in ruler area) */} + <div + className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} + /> + </div> + ); +} + +// Also export a hook for getting ruler handlers +export function useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { + const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + return { handleRulerMouseDown, isDraggingRuler }; +} + +export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 82ca0b98..6c5a5033 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,1151 +1,1151 @@ -"use client";
-
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { TimelineElement } from "./timeline-element";
-import {
- TimelineTrack,
- sortTracksByOrder,
- ensureMainTrack,
- getMainTrack,
- canElementGoOnTrack,
-} from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import type {
- TimelineElement as TimelineElementType,
- DragData,
-} from "@/types/timeline";
-import {
- snapTimeToFrame,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- onSnapPointChange,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- addTrack,
- moveElementToTrack,
- updateElementStartTime,
- updateElementStartTimeWithRipple,
- addElementToTrack,
- selectedElements,
- selectElement,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- clearSelectedElements,
- insertTrackAt,
- snappingEnabled,
- rippleEditingEnabled,
- } = useTimelineStore();
-
- const { currentTime } = usePlaybackStore();
-
- // Initialize snapping hook
- const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
- snapThreshold: 10,
- enableElementSnapping: snappingEnabled,
- enablePlayheadSnapping: snappingEnabled,
- });
-
- // Helper function for drop snapping that tries both edges
- const getDropSnappedTime = (
- dropTime: number,
- elementDuration: number,
- excludeElementId?: string
- ) => {
- if (!snappingEnabled) {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- return snapTimeToFrame(dropTime, projectFps);
- }
-
- // Try snapping both start and end edges for drops
- const startSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- dropTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- excludeElementId,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- return bestSnapResult.snappedTime;
- };
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- // On first mouse move during drag, ensure the element is selected
- if (dragState.elementId && dragState.trackId) {
- const isSelected = selectedElements.some(
- (c) =>
- c.trackId === dragState.trackId &&
- c.elementId === dragState.elementId
- );
-
- if (!isSelected) {
- // Select this element (replacing other selections) since we're dragging it
- selectElement(dragState.trackId, dragState.elementId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- );
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
-
- // Apply snapping if enabled
- let finalTime = adjustedTime;
- let snapPoint = null;
- if (snappingEnabled) {
- // Find the element being dragged to get its duration
- let elementDuration = 5; // fallback duration
- if (dragState.elementId && dragState.trackId) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const element = sourceTrack?.elements.find(
- (e) => e.id === dragState.elementId
- );
- if (element) {
- elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- }
- }
-
- // Try snapping both start and end edges
- const startSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- true // snap to start edge
- );
-
- const endSnapResult = snapElementEdge(
- adjustedTime,
- elementDuration,
- tracks,
- currentTime,
- zoomLevel,
- dragState.elementId || undefined,
- false // snap to end edge
- );
-
- // Choose the snap result with the smaller distance (closer snap)
- let bestSnapResult = startSnapResult;
- if (
- endSnapResult.snapPoint &&
- (!startSnapResult.snapPoint ||
- endSnapResult.snapDistance < startSnapResult.snapDistance)
- ) {
- bestSnapResult = endSnapResult;
- }
-
- finalTime = bestSnapResult.snappedTime;
- snapPoint = bestSnapResult.snapPoint;
-
- // Notify parent component about snap point change
- onSnapPointChange?.(snapPoint);
- } else {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- finalTime = snapTimeToFrame(adjustedTime, projectFps);
-
- // Clear snap point when not snapping
- onSnapPointChange?.(null);
- }
-
- updateDragTime(finalTime);
- };
-
- const handleMouseUp = (e: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
- const isTrackThatStartedDrag = dragState.trackId === track.id;
-
- const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) {
- if (isTrackThatStartedDrag) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- }
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- return;
- }
-
- const isMouseOverThisTrack =
- e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
-
- if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
-
- const finalTime = dragState.currentTime;
-
- if (isMouseOverThisTrack) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- finalTime
- );
- }
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId!,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
- }
- });
- }
- }
- }
- } else if (isTrackThatStartedDrag) {
- // Mouse is not over this track, but this track started the drag
- // This means user released over ruler/outside - update position within same track
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- if (existingElement.id === dragState.elementId) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
- }
- }
- }
- }
-
- if (isTrackThatStartedDrag) {
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateElementStartTime,
- moveElementToTrack,
- endDragAction,
- selectedElements,
- selectElement,
- onSnapPointChange,
- ]);
-
- const handleElementMouseDown = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- // If element is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectElement(track.id, element.id, isMultiSelect);
- }
- // If element is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectElement(track.id, element.id, true);
- }
-
- // Calculate the offset from the left edge of the element to where the user clicked
- const elementElement = e.currentTarget as HTMLElement;
- const elementRect = elementElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - elementRect.left;
- const clickOffsetTime =
- clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
-
- startDragAction(
- element.id,
- track.id,
- e.clientX,
- element.startTime,
- clickOffsetTime
- );
- };
-
- const handleElementClick = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- if (!isSelected) {
- // If element is not selected, select it (replacing other selections)
- selectElement(track.id, element.id, false);
- }
- // If element is already selected, keep it selected (do nothing)
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline elements and media items
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- // Text elements have default duration of 5 seconds
- const newElementDuration = 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return snappedTime < existingEnd && newElementEnd > existingStart;
- });
- } else {
- // Media elements
- const mediaItem = mediaItems.find(
- (item) => item.id === dragData.id
- );
- if (mediaItem) {
- const newElementDuration = mediaItem.duration || 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && newElementEnd > existingStart
- );
- });
- }
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineElement) {
- try {
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (timelineElementData) {
- const { elementId, trackId: fromTrackId } =
- JSON.parse(timelineElementData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: any) => c.id === elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const snappedTime = getDropSnappedTime(
- dropTime,
- movingElementDuration,
- elementId
- );
- const movingElementEnd = snappedTime + movingElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && movingElementEnd > existingStart
- );
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
- setWouldOverlap(false);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Debug logging
- console.log(
- JSON.stringify({
- message: "Drop event started in timeline track",
- dataTransferTypes: Array.from(e.dataTransfer.types),
- trackId: track.id,
- trackType: track.type,
- })
- );
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const mouseY = e.clientY - rect.top; // Get Y position relative to this track
- const newStartTime =
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- const snappedTime = snapTimeToFrame(newStartTime, projectFps);
-
- // Calculate drop position relative to tracks
- const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
-
- // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
- let dropPosition: "above" | "on" | "below";
- if (mouseY < 20) {
- dropPosition = "above";
- } else if (mouseY > 40) {
- dropPosition = "below";
- } else {
- dropPosition = "on";
- }
-
- try {
- if (hasTimelineElement) {
- // Handle timeline element movement
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (!timelineElementData) return;
-
- const {
- elementId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineElementData);
-
- // Find the element being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: TimelineElementType) => c.id === elementId
- );
-
- if (!movingElement) {
- toast.error("Element not found");
- return;
- }
-
- // Check for overlaps with existing elements (excluding the moving element itself)
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
-
- // Adjust position based on where user clicked on the element
- const adjustedStartTime = newStartTime - clickOffsetTime;
- const snappedStartTime = getDropSnappedTime(
- adjustedStartTime,
- movingElementDuration,
- elementId
- );
- const finalStartTime = Math.max(0, snappedStartTime);
- const movingElementEnd = finalStartTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- // Skip the element being moved if it's on the same track
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- finalStartTime < existingEnd && movingElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move element here - it would overlap with existing elements"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- } else {
- // Moving to different track
- moveElementToTrack(fromTrackId, track.id, elementId);
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- let targetTrackId = track.id;
- let targetTrack = track;
-
- // Handle position-aware track creation for text
- if (track.type !== "text" || dropPosition !== "on") {
- // Text tracks should go above the main track
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // dropPosition === "on" but track is not text type
- // Insert above main track if main track exists, otherwise at top
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- } else {
- insertIndex = 0; // Top of timeline
- }
- }
-
- targetTrackId = insertTrackAt("text", insertIndex);
- // Get the updated tracks array after creating the new track
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = 5; // Default text duration
- const textSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = textSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- textSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "text",
- name: dragData.name || "Text",
- content: dragData.content || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: textSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- });
- } else {
- // Handle media items
- const mediaItem = mediaItems.find((item) => item.id === dragData.id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- let targetTrackId = track.id;
-
- // Check if track type is compatible
- const isVideoOrImage =
- dragData.type === "video" || dragData.type === "image";
- const isAudio = dragData.type === "audio";
- const isCompatible = isVideoOrImage
- ? canElementGoOnTrack("media", track.type)
- : isAudio
- ? canElementGoOnTrack("media", track.type)
- : false;
-
- let targetTrack = tracks.find((t) => t.id === targetTrackId);
-
- // Handle position-aware track creation for media elements
- if (!isCompatible || dropPosition !== "on") {
- if (isVideoOrImage) {
- // For video/image, check if we need a main track or additional media track
- const mainTrack = getMainTrack(tracks);
-
- if (!mainTrack) {
- // No main track exists, create it
- targetTrackId = addTrack("media");
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- } else if (
- mainTrack.elements.length === 0 &&
- dropPosition === "on"
- ) {
- // Main track exists and is empty, use it
- targetTrackId = mainTrack.id;
- targetTrack = mainTrack;
- } else {
- // Create new media track
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert above main track
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- }
-
- targetTrackId = insertTrackAt("media", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (isAudio) {
- // Audio tracks go at the bottom
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert after main track (bottom area)
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex + 1;
- } else {
- insertIndex = tracks.length; // Bottom of timeline
- }
- }
-
- targetTrackId = insertTrackAt("audio", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- }
-
- if (!targetTrack) return;
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = mediaItem.duration || 5;
- const mediaSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = mediaSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- mediaSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: mediaSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on an element), deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-elements-container min-w-full"
- >
- {track.elements.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
- >
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop element here"
- : ""}
- </div>
- ) : (
- <>
- {track.elements.map((element) => {
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- const handleElementSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (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");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
-
- const handleElementDuplicate = () => {
- const { addElementToTrack } = useTimelineStore.getState();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
-
- const handleElementDelete = () => {
- const { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
- };
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { toast } from "sonner"; +import { TimelineElement } from "./timeline-element"; +import { + TimelineTrack, + sortTracksByOrder, + ensureMainTrack, + getMainTrack, + canElementGoOnTrack, +} from "@/types/timeline"; +import { usePlaybackStore } from "@/stores/playback-store"; +import type { + TimelineElement as TimelineElementType, + DragData, +} from "@/types/timeline"; +import { + snapTimeToFrame, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; + +export function TimelineTrackContent({ + track, + zoomLevel, + onSnapPointChange, +}: { + track: TimelineTrack; + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}) { + const { mediaItems } = useMediaStore(); + const { + tracks, + addTrack, + moveElementToTrack, + updateElementStartTime, + updateElementStartTimeWithRipple, + addElementToTrack, + selectedElements, + selectElement, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + clearSelectedElements, + insertTrackAt, + snappingEnabled, + rippleEditingEnabled, + } = useTimelineStore(); + + const { currentTime } = usePlaybackStore(); + + // Initialize snapping hook + const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ + snapThreshold: 10, + enableElementSnapping: snappingEnabled, + enablePlayheadSnapping: snappingEnabled, + }); + + // Helper function for drop snapping that tries both edges + const getDropSnappedTime = ( + dropTime: number, + elementDuration: number, + excludeElementId?: string, + ) => { + if (!snappingEnabled) { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + return snapTimeToFrame(dropTime, projectFps); + } + + // Try snapping both start and end edges for drops + const startSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + true, // snap to start edge + ); + + const endSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + false, // snap to end edge + ); + + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + return bestSnapResult.snappedTime; + }; + + const timelineRef = useRef<HTMLDivElement>(null); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState<number | null>(null); + const [wouldOverlap, setWouldOverlap] = useState(false); + const dragCounterRef = useRef(0); + const [mouseDownLocation, setMouseDownLocation] = useState<{ + x: number; + y: number; + } | null>(null); + + // Set up mouse event listeners for drag + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + // On first mouse move during drag, ensure the element is selected + if (dragState.elementId && dragState.trackId) { + const isSelected = selectedElements.some( + (c) => + c.trackId === dragState.trackId && + c.elementId === dragState.elementId, + ); + + if (!isSelected) { + // Select this element (replacing other selections) since we're dragging it + selectElement(dragState.trackId, dragState.elementId, false); + } + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + + // Apply snapping if enabled + let finalTime = adjustedTime; + let snapPoint = null; + if (snappingEnabled) { + // Find the element being dragged to get its duration + let elementDuration = 5; // fallback duration + if (dragState.elementId && dragState.trackId) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const element = sourceTrack?.elements.find( + (e) => e.id === dragState.elementId, + ); + if (element) { + elementDuration = + element.duration - element.trimStart - element.trimEnd; + } + } + + // Try snapping both start and end edges + const startSnapResult = snapElementEdge( + adjustedTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + dragState.elementId || undefined, + true, // snap to start edge + ); + + const endSnapResult = snapElementEdge( + adjustedTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + dragState.elementId || undefined, + false, // snap to end edge + ); + + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.snapPoint; + + // Notify parent component about snap point change + onSnapPointChange?.(snapPoint); + } else { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + finalTime = snapTimeToFrame(adjustedTime, projectFps); + + // Clear snap point when not snapping + onSnapPointChange?.(null); + } + + updateDragTime(finalTime); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + // If this track initiated the drag, we should handle the mouse up regardless of where it occurs + const isTrackThatStartedDrag = dragState.trackId === track.id; + + const timelineRect = timelineRef.current?.getBoundingClientRect(); + if (!timelineRect) { + if (isTrackThatStartedDrag) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + dragState.currentTime, + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId, + dragState.currentTime, + ); + } + endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); + } + return; + } + + const isMouseOverThisTrack = + e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; + + if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; + + const finalTime = dragState.currentTime; + + if (isMouseOverThisTrack) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId, + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.elements.some((existingElement) => { + if ( + dragState.trackId === track.id && + existingElement.id === dragState.elementId + ) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + finalTime, + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId, + finalTime, + ); + } + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId, + ); + requestAnimationFrame(() => { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId!, + finalTime, + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId!, + finalTime, + ); + } + }); + } + } + } + } else if (isTrackThatStartedDrag) { + // Mouse is not over this track, but this track started the drag + // This means user released over ruler/outside - update position within same track + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId, + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + if (existingElement.id === dragState.elementId) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + finalTime, + ); + } else { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } + } + } + } + + if (isTrackThatStartedDrag) { + endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + track.id, + updateDragTime, + updateElementStartTime, + moveElementToTrack, + endDragAction, + selectedElements, + selectElement, + onSnapPointChange, + ]); + + const handleElementMouseDown = ( + e: React.MouseEvent, + element: TimelineElementType, + ) => { + setMouseDownLocation({ x: e.clientX, y: e.clientY }); + + // Detect right-click (button 2) and handle selection without starting drag + const isRightClick = e.button === 2; + const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; + + if (isRightClick) { + // Handle right-click selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id, + ); + + // If element is not selected, select it (keep other selections if multi-select) + if (!isSelected) { + selectElement(track.id, element.id, isMultiSelect); + } + // If element is already selected, keep it selected + + // Don't start drag action for right-clicks + return; + } + + // Handle multi-selection for left-click with modifiers + if (isMultiSelect) { + selectElement(track.id, element.id, true); + } + + // Calculate the offset from the left edge of the element to where the user clicked + const elementElement = e.currentTarget as HTMLElement; + const elementRect = elementElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - elementRect.left; + const clickOffsetTime = + clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + + startDragAction( + element.id, + track.id, + e.clientX, + element.startTime, + clickOffsetTime, + ); + }; + + const handleElementClick = ( + e: React.MouseEvent, + element: TimelineElementType, + ) => { + e.stopPropagation(); + + // Check if mouse moved significantly + if (mouseDownLocation) { + const deltaX = Math.abs(e.clientX - mouseDownLocation.x); + const deltaY = Math.abs(e.clientY - mouseDownLocation.y); + // If it moved more than a few pixels, consider it a drag and not a click. + if (deltaX > 5 || deltaY > 5) { + setMouseDownLocation(null); // Reset for next interaction + return; + } + } + + // Skip selection logic for multi-selection (handled in mousedown) + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + + // Handle single selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id, + ); + + if (!isSelected) { + // If element is not selected, select it (replacing other selections) + selectElement(track.id, element.id, false); + } + // If element is already selected, keep it selected (do nothing) + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Handle both timeline elements and media items + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item", + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + // Calculate drop position for overlap checking + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container", + ) as HTMLElement; + let dropTime = 0; + if (trackContainer) { + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + } + + // Check for potential overlaps and show appropriate feedback + let wouldOverlap = false; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item", + ); + if (mediaItemData) { + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + // Text elements have default duration of 5 seconds + const newElementDuration = 5; + const snappedTime = getDropSnappedTime( + dropTime, + newElementDuration, + ); + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + } else { + // Media elements + const mediaItem = mediaItems.find( + (item) => item.id === dragData.id, + ); + if (mediaItem) { + const newElementDuration = mediaItem.duration || 5; + const snappedTime = getDropSnappedTime( + dropTime, + newElementDuration, + ); + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && newElementEnd > existingStart + ); + }); + } + } + } + } catch (error) { + // Continue with default behavior + } + } else if (hasTimelineElement) { + try { + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element", + ); + if (timelineElementData) { + const { elementId, trackId: fromTrackId } = + JSON.parse(timelineElementData); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId, + ); + const movingElement = sourceTrack?.elements.find( + (c: any) => c.id === elementId, + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const snappedTime = getDropSnappedTime( + dropTime, + movingElementDuration, + elementId, + ); + const movingElementEnd = snappedTime + movingElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && movingElementEnd > existingStart + ); + }); + } + } + } catch (error) { + // Continue with default behavior + } + } + + if (wouldOverlap) { + e.dataTransfer.dropEffect = "none"; + setWouldOverlap(true); + // Use default duration for position indicator + setDropPosition(getDropSnappedTime(dropTime, 5)); + return; + } + + e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; + setWouldOverlap(false); + // Use default duration for position indicator + setDropPosition(getDropSnappedTime(dropTime, 5)); + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item", + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current++; + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item", + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDropping(false); + setWouldOverlap(false); + setDropPosition(null); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Debug logging + console.log( + JSON.stringify({ + message: "Drop event started in timeline track", + dataTransferTypes: Array.from(e.dataTransfer.types), + trackId: track.id, + trackType: track.type, + }), + ); + + // Reset all drag states + dragCounterRef.current = 0; + setIsDropping(false); + setWouldOverlap(false); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item", + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container", + ) as HTMLElement; + if (!trackContainer) return; + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const mouseY = e.clientY - rect.top; // Get Y position relative to this track + const newStartTime = + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const snappedTime = snapTimeToFrame(newStartTime, projectFps); + + // Calculate drop position relative to tracks + const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); + + // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) + let dropPosition: "above" | "on" | "below"; + if (mouseY < 20) { + dropPosition = "above"; + } else if (mouseY > 40) { + dropPosition = "below"; + } else { + dropPosition = "on"; + } + + try { + if (hasTimelineElement) { + // Handle timeline element movement + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element", + ); + if (!timelineElementData) return; + + const { + elementId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineElementData); + + // Find the element being moved + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId, + ); + const movingElement = sourceTrack?.elements.find( + (c: TimelineElementType) => c.id === elementId, + ); + + if (!movingElement) { + toast.error("Element not found"); + return; + } + + // Check for overlaps with existing elements (excluding the moving element itself) + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + + // Adjust position based on where user clicked on the element + const adjustedStartTime = newStartTime - clickOffsetTime; + const snappedStartTime = getDropSnappedTime( + adjustedStartTime, + movingElementDuration, + elementId, + ); + const finalStartTime = Math.max(0, snappedStartTime); + const movingElementEnd = finalStartTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + // Skip the element being moved if it's on the same track + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + finalStartTime < existingEnd && movingElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot move element here - it would overlap with existing elements", + ); + return; + } + + if (fromTrackId === track.id) { + // Moving within same track + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + elementId, + finalStartTime, + ); + } else { + updateElementStartTime(track.id, elementId, finalStartTime); + } + } else { + // Moving to different track + moveElementToTrack(fromTrackId, track.id, elementId); + requestAnimationFrame(() => { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + elementId, + finalStartTime, + ); + } else { + updateElementStartTime(track.id, elementId, finalStartTime); + } + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item", + ); + if (!mediaItemData) return; + + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + let targetTrackId = track.id; + let targetTrack = track; + + // Handle position-aware track creation for text + if (track.type !== "text" || dropPosition !== "on") { + // Text tracks should go above the main track + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is not text type + // Insert above main track if main track exists, otherwise at top + if (mainTrack) { + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id, + ); + insertIndex = mainTrackIndex; + } else { + insertIndex = 0; // Top of timeline + } + } + + targetTrackId = insertTrackAt("text", insertIndex); + // Get the updated tracks array after creating the new track + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId, + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + + // Check for overlaps with existing elements in target track + const newElementDuration = 5; // Default text duration + const textSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration, + ); + const newElementEnd = textSnappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + textSnappedTime < existingEnd && newElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements", + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: textSnappedTime, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.find((item) => item.id === dragData.id); + + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + let targetTrackId = track.id; + + // Check if track type is compatible + const isVideoOrImage = + dragData.type === "video" || dragData.type === "image"; + const isAudio = dragData.type === "audio"; + const isCompatible = isVideoOrImage + ? canElementGoOnTrack("media", track.type) + : isAudio + ? canElementGoOnTrack("media", track.type) + : false; + + let targetTrack = tracks.find((t) => t.id === targetTrackId); + + // Handle position-aware track creation for media elements + if (!isCompatible || dropPosition !== "on") { + if (isVideoOrImage) { + // For video/image, check if we need a main track or additional media track + const mainTrack = getMainTrack(tracks); + + if (!mainTrack) { + // No main track exists, create it + targetTrackId = addTrack("media"); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId, + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } else if ( + mainTrack.elements.length === 0 && + dropPosition === "on" + ) { + // Main track exists and is empty, use it + targetTrackId = mainTrack.id; + targetTrack = mainTrack; + } else { + // Create new media track + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // Insert above main track + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id, + ); + insertIndex = mainTrackIndex; + } + + targetTrackId = insertTrackAt("media", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId, + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } else if (isAudio) { + // Audio tracks go at the bottom + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // Insert after main track (bottom area) + if (mainTrack) { + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id, + ); + insertIndex = mainTrackIndex + 1; + } else { + insertIndex = tracks.length; // Bottom of timeline + } + } + + targetTrackId = insertTrackAt("audio", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId, + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } + + if (!targetTrack) return; + + // Check for overlaps with existing elements in target track + const newElementDuration = mediaItem.duration || 5; + const mediaSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration, + ); + const newElementEnd = mediaSnappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + mediaSnappedTime < existingEnd && newElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements", + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "media", + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: mediaSnappedTime, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + return ( + <div + className="w-full h-full hover:bg-muted/20" + onClick={(e) => { + // If clicking empty area (not on an element), deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + > + <div + ref={timelineRef} + className="h-full relative track-elements-container min-w-full" + > + {track.elements.length === 0 ? ( + <div + className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ + isDropping + ? wouldOverlap + ? "border-red-500 bg-red-500/10 text-red-600" + : "border-blue-500 bg-blue-500/10 text-blue-600" + : "border-muted/30" + }`} + > + {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop element here" + : ""} + </div> + ) : ( + <> + {track.elements.map((element) => { + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id, + ); + + const handleElementSplit = () => { + const { currentTime } = usePlaybackStore(); + const { splitElement } = useTimelineStore(); + const splitTime = currentTime; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (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"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicate = () => { + const { addElementToTrack } = useTimelineStore.getState(); + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDelete = () => { + const { removeElementFromTrack } = useTimelineStore.getState(); + removeElementFromTrack(track.id, element.id); + }; + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + /> + ); + })} + </> + )} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 9e68fd4d..a02eaaad 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -3,33 +3,33 @@ import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Video, - Music, - TypeIcon, - Lock, - LockOpen, - Link, + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, + Video, + Music, + TypeIcon, + Lock, + LockOpen, + Link, } from "lucide-react"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, } from "../ui/tooltip"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, } from "../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; @@ -41,8 +41,8 @@ import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { - TimelinePlayhead, - useTimelinePlayheadRuler, + TimelinePlayhead, + useTimelinePlayheadRuler, } from "./timeline-playhead"; import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; @@ -50,1046 +50,1047 @@ import { SnapIndicator } from "./snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, - TIMELINE_CONSTANTS, - snapTimeToFrame, + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + TIMELINE_CONSTANTS, + snapTimeToFrame, } from "@/constants/timeline-constants"; export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their elements. - // You can drag media here to add it to your project. - // elements can be trimmed, deleted, and moved. + // Timeline shows all tracks (video, audio, effects) and their elements. + // You can drag media here to add it to your project. + // elements can be trimmed, deleted, and moved. - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - getTotalDuration, - selectedElements, - clearSelectedElements, - setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - dragState, - } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); - const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const dragCounterRef = useRef(0); - const timelineRef = useRef<HTMLDivElement>(null); - const rulerRef = useRef<HTMLDivElement>(null); - const [isInTimeline, setIsInTimeline] = useState(false); + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + getTotalDuration, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + toggleTrackMute, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + dragState, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const dragCounterRef = useRef(0); + const timelineRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const [isInTimeline, setIsInTimeline] = useState(false); - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); + // Track mouse down/up for distinguishing clicks from drag/resize ends + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); + // Timeline zoom functionality + const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ + containerRef: timelineRef, + isInTimeline, + }); - // Old marquee selection removed - using new SelectionBox component instead + // Old marquee selection removed - using new SelectionBox component instead - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer - timelineRef.current?.clientWidth || 1000 // Minimum width - ); + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration + (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000, // Minimum width + ); - // Scroll synchronization and auto-scroll to playhead - const rulerScrollRef = useRef<HTMLDivElement>(null); - const tracksScrollRef = useRef<HTMLDivElement>(null); - const trackLabelsRef = useRef<HTMLDivElement>(null); - const playheadRef = useRef<HTMLDivElement>(null); - const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - const isUpdatingRef = useRef(false); - const lastRulerSync = useRef(0); - const lastTracksSync = useRef(0); - const lastVerticalSync = useRef(0); + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); + // Timeline playhead ruler handlers + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); - // Selection box functionality - const tracksContainerRef = useRef<HTMLDivElement>(null); - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - justFinishedSelecting, - } = useSelectionBox({ - containerRef: tracksContainerRef, - playheadRef, - onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(elements); - }, - }); + // Selection box functionality + const tracksContainerRef = useRef<HTMLDivElement>(null); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + justFinishedSelecting, + } = useSelectionBox({ + containerRef: tracksContainerRef, + playheadRef, + onSelectionComplete: (elements) => { + console.log(JSON.stringify({ onSelectionComplete: elements.length })); + setSelectedElements(elements); + }, + }); - // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( - null - ); - const showSnapIndicator = - dragState.isDragging && snappingEnabled && currentSnapPoint !== null; + // Calculate snap indicator state + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); + const showSnapIndicator = + dragState.isDragging && snappingEnabled && currentSnapPoint !== null; - // Callback to handle snap point changes from TimelineTrackContent - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); + // Callback to handle snap point changes from TimelineTrackContent + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); + // Track mouse down to distinguish real clicks from drag/resize ends + const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { + // Only track mouse down on timeline background areas (not elements) + const target = e.target as HTMLElement; + const isTimelineBackground = + !target.closest(".timeline-element") && + !playheadRef.current?.contains(target) && + !target.closest("[data-track-labels]"); - if (isTimelineBackground) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: e.timeStamp, - }; - } - }, []); + if (isTimelineBackground) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: e.clientX, + downY: e.clientY, + downTime: e.timeStamp, + }; + } + }, []); - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - // Reset mouse tracking - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; + // Reset mouse tracking + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; - // Only process as click if we tracked a mouse down on timeline background - if (!isMouseDown) { - console.log( - JSON.stringify({ - ignoredClickWithoutMouseDown: true, - timeStamp: e.timeStamp, - }) - ); - return; - } + // Only process as click if we tracked a mouse down on timeline background + if (!isMouseDown) { + console.log( + JSON.stringify({ + ignoredClickWithoutMouseDown: true, + timeStamp: e.timeStamp, + }), + ); + return; + } - // Check if mouse moved significantly (indicates drag, not click) - const deltaX = Math.abs(e.clientX - downX); - const deltaY = Math.abs(e.clientY - downY); - const deltaTime = e.timeStamp - downTime; + // Check if mouse moved significantly (indicates drag, not click) + const deltaX = Math.abs(e.clientX - downX); + const deltaY = Math.abs(e.clientY - downY); + const deltaTime = e.timeStamp - downTime; - if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { - console.log( - JSON.stringify({ - ignoredDragNotClick: true, - deltaX, - deltaY, - deltaTime, - timeStamp: e.timeStamp, - }) - ); - return; - } + if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { + console.log( + JSON.stringify({ + ignoredDragNotClick: true, + deltaX, + deltaY, + deltaTime, + timeStamp: e.timeStamp, + }), + ); + return; + } - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - return; - } + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - return; - } + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - clearSelectedElements(); - return; - } + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } - // Clear selected elements when clicking empty timeline area - console.log(JSON.stringify({ clearingSelectedElements: true })); - clearSelectedElements(); + // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); + clearSelectedElements(); - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); + // Determine if we're clicking in ruler or tracks area + const isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]", + ); - let mouseX: number; - let scrollLeft = 0; + let mouseX: number; + let scrollLeft = 0; - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!rulerContent) return; - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - // Calculate based on tracks content position - const tracksContent = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!tracksContent) return; - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } + if (isRulerClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - ] - ); + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + ], + ); - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); - // Old marquee system removed - using new SelectionBox component instead + // Old marquee system removed - using new SelectionBox component instead - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/element + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - if (hasTimelineElement) { - return; - } + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + if (hasTimelineElement) { + return; + } - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { + try { + const dragData: DragData = JSON.parse(itemData); - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaItems.find( - (item: any) => item.id === dragData.id - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + useTimelineStore.getState().addTextToNewTrack(dragData); + } else { + // Handle media items + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id, + ); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); - } - } catch (error) { - console.error("Error parsing dropped item data:", error); - toast.error("Failed to add item to timeline"); - } - } else if (e.dataTransfer.files?.length > 0) { - // Handle file drops by creating new tracks - if (!activeProject) { - toast.error("No active project"); - return; - } + useTimelineStore.getState().addMediaToNewTrack(mediaItem); + } + } catch (error) { + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); - for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( - (item) => - item.name === processedItem.name && item.url === processedItem.url - ); - if (addedItem) { - useTimelineStore.getState().addMediaToNewTrack(addedItem); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - } - }; + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p), + ); + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && + item.url === processedItem.url, + ); + if (addedItem) { + useTimelineStore.getState().addMediaToNewTrack(addedItem); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; - // Action handlers for toolbar - const handleSplitSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - 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); + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + 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"); - } - }; + 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) { - toast.error("No elements selected"); - return; - } - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; - const newSelections: { trackId: string; elementId: string }[] = []; + const newSelections: { trackId: string; elementId: string }[] = []; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); + 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; + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; - // Create element without id (will be generated by store) - const { id, ...elementWithoutId } = element; + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); - // We can't predict the new id, so just clear selection for now - // TODO: addElementToTrack could return the new element id - } - }); + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id + } + }); - clearSelectedElements(); - }; + clearSelectedElements(); + }; - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; + 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 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 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 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) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; + const handleDeleteSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; - // --- Scroll synchronization effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; - if (!rulerViewport || !tracksViewport) return; + if (!rulerViewport || !tracksViewport) return; - // Horizontal scroll synchronization between ruler and tracks - const handleRulerScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; - lastRulerSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - isUpdatingRef.current = false; - }; - const handleTracksScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; - lastTracksSync.current = now; - isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - isUpdatingRef.current = false; - }; + // Horizontal scroll synchronization between ruler and tracks + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; - rulerViewport.addEventListener("scroll", handleRulerScroll); - tracksViewport.addEventListener("scroll", handleTracksScroll); + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); - // Vertical scroll synchronization between track labels and tracks content - if (trackLabelsViewport) { - const handleTrackLabelsScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollTop = trackLabelsViewport.scrollTop; - isUpdatingRef.current = false; - }; - const handleTracksVerticalScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - trackLabelsViewport.scrollTop = tracksViewport.scrollTop; - isUpdatingRef.current = false; - }; + // Vertical scroll synchronization between track labels and tracks content + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; - trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); - tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - trackLabelsViewport.removeEventListener( - "scroll", - handleTrackLabelsScroll - ); - tracksViewport.removeEventListener( - "scroll", - handleTracksVerticalScroll - ); - }; - } + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll, + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll, + ); + }; + } - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - }; - }, []); + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); - return ( - <div - className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} - {...dragProps} - onMouseEnter={() => setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - {/* Toolbar */} - <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}> - {/* Play/Pause Button */} - <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> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} - <div - className="text-xs text-muted-foreground font-mono px-2" - style={{ minWidth: "18ch", textAlign: "center" }} - > - {currentTime.toFixed(1)}s / {duration.toFixed(1)}s - </div> - {/* Test Clip Button - for debugging */} - {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> - </TooltipProvider> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <LockOpen className="h-4 w-4 text-primary" /> - ) : ( - <Lock 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> - </div> + return ( + <div + className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} + {...dragProps} + onMouseEnter={() => setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + > + {/* Toolbar */} + <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}> + {/* Play/Pause Button */} + <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> + <div className="w-px h-6 bg-border mx-1" /> + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + {/* Test Clip Button - for debugging */} + {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> + </TooltipProvider> + </div> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleSnapping}> + {snappingEnabled ? ( + <LockOpen className="h-4 w-4 text-primary" /> + ) : ( + <Lock 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> + </div> - {/* Timeline Container */} - <div - className="flex-1 flex flex-col overflow-hidden relative" - ref={timelineRef} - > - <TimelinePlayhead - currentTime={currentTime} - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - trackLabelsRef={trackLabelsRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} - trackLabelsRef={trackLabelsRef} - isVisible={showSnapIndicator} - /> - {/* Timeline Header with Ruler */} - <div className="flex bg-panel sticky top-0 z-10"> - {/* Track Labels Header */} - <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> - {/* Empty space */} - <span className="text-sm font-medium text-muted-foreground opacity-0"> - . - </span> - </div> + {/* Timeline Container */} + <div + className="flex-1 flex flex-col overflow-hidden relative" + ref={timelineRef} + > + <TimelinePlayhead + currentTime={currentTime} + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + seek={seek} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + trackLabelsRef={trackLabelsRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + isVisible={showSnapIndicator} + /> + {/* Timeline Header with Ruler */} + <div className="flex bg-panel sticky top-0 z-10"> + {/* Track Labels Header */} + <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> + {/* Empty space */} + <span className="text-sm font-medium text-muted-foreground opacity-0"> + . + </span> + </div> - {/* Timeline Ruler */} - <div - className="flex-1 relative overflow-hidden h-4" - onWheel={handleWheel} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - > - <ScrollArea className="w-full" ref={rulerScrollRef}> - <div - ref={rulerRef} - className="relative h-4 select-none cursor-default" - style={{ - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > - {/* Time markers */} - {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; + {/* Timeline Ruler */} + <div + className="flex-1 relative overflow-hidden h-4" + onWheel={handleWheel} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + data-ruler-area + > + <ScrollArea className="w-full" ref={rulerScrollRef}> + <div + ref={rulerRef} + className="relative h-4 select-none cursor-default" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {/* Time markers */} + {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = + TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - return ( - <div - key={i} - className={`absolute top-0 bottom-0 ${ - 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; + return ( + <div + key={i} + className={`absolute top-0 bottom-0 ${ + 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")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - </span> - </div> - ); - }).filter(Boolean); - })()} - </div> - </ScrollArea> - </div> - </div> + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + return formatTime(time); + })()} + </span> + </div> + ); + }).filter(Boolean); + })()} + </div> + </ScrollArea> + </div> + </div> - {/* Tracks Area */} - <div className="flex-1 flex overflow-hidden"> - {/* Track Labels */} - {tracks.length > 0 && ( - <div - ref={trackLabelsRef} - className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" - data-track-labels - > - <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> - <div className="flex flex-col gap-1"> - {tracks.map((track) => ( - <div - key={track.id} - className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" - style={{ height: `${getTrackHeight(track.type)}px` }} - > - <div className="flex items-center flex-1 min-w-0"> - <TrackIcon track={track} /> - </div> - {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> - Muted - </span> - )} - </div> - ))} - </div> - </ScrollArea> - </div> - )} + {/* Tracks Area */} + <div className="flex-1 flex overflow-hidden"> + {/* Track Labels */} + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" + data-track-labels + > + <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + style={{ height: `${getTrackHeight(track.type)}px` }} + > + <div className="flex items-center flex-1 min-w-0"> + <TrackIcon track={track} /> + </div> + {track.muted && ( + <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + Muted + </span> + )} + </div> + ))} + </div> + </ScrollArea> + </div> + )} - {/* Timeline Tracks Content */} - <div - className="flex-1 relative overflow-hidden" - onWheel={handleWheel} - onMouseDown={(e) => { - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <ScrollArea className="w-full h-full" ref={tracksScrollRef}> - <div - className="relative flex-1" - style={{ - height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, - width: `${dynamicTimelineWidth}px`, - }} - > - {tracks.length === 0 ? ( - <div></div> - ) : ( - <> - {tracks.map((track, index) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" - style={{ - top: `${getCumulativeHeightBefore(tracks, index)}px`, - height: `${getTrackHeight(track.type)}px`, - }} - onClick={(e) => { - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element" - ) - ) { - clearSelectedElements(); - } - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - onSnapPointChange={handleSnapPointChange} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem - onClick={() => toggleTrackMute(track.id)} - > - {track.muted ? "Unmute Track" : "Mute Track"} - </ContextMenuItem> - <ContextMenuItem> - Track settings (soon) - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </> - )} - </div> - </ScrollArea> - </div> - </div> - </div> - </div> - ); + {/* Timeline Tracks Content */} + <div + className="flex-1 relative overflow-hidden" + onWheel={handleWheel} + onMouseDown={(e) => { + handleTimelineMouseDown(e); + handleSelectionMouseDown(e); + }} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <ScrollArea className="w-full h-full" ref={tracksScrollRef}> + <div + className="relative flex-1" + style={{ + height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, + width: `${dynamicTimelineWidth}px`, + }} + > + {tracks.length === 0 ? ( + <div></div> + ) : ( + <> + {tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + style={{ + top: `${getCumulativeHeightBefore(tracks, index)}px`, + height: `${getTrackHeight(track.type)}px`, + }} + onClick={(e) => { + // If clicking empty area (not on a element), deselect all elements + if ( + !(e.target as HTMLElement).closest( + ".timeline-element", + ) + ) { + clearSelectedElements(); + } + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + onSnapPointChange={handleSnapPointChange} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem + onClick={() => toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + </ContextMenuItem> + <ContextMenuItem> + Track settings (soon) + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </> + )} + </div> + </ScrollArea> + </div> + </div> + </div> + </div> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( - <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - {track.type === "text" && ( - <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - {track.type === "audio" && ( - <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - </> - ); + return ( + <> + {track.type === "media" && ( + <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "text" && ( + <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "audio" && ( + <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + </> + ); } diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 5b7f2303..1385e377 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -8,133 +8,133 @@ import { getStars } from "@/lib/fetch-github-stars"; import Image from "next/image"; export function Footer() { - const [star, setStar] = useState<string>(); + 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); - } - }; + useEffect(() => { + const fetchStars = async () => { + try { + const data = await getStars(); + setStar(data); + } catch (err) { + console.error("Failed to fetch GitHub stars", err); + } + }; - fetchStars(); - }, []); + fetchStars(); + }, []); - return ( - <motion.footer - className="bg-background border-t" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.8, duration: 0.8 }} - > - <div className="max-w-5xl mx-auto px-8 py-10"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8"> - {/* Brand Section */} - <div className="md:col-span-1 max-w-sm"> - <div className="flex justify-start items-center gap-2 mb-4"> - <Image src="/logo.svg" alt="OpenCut" width={24} height={24} /> - <span className="font-bold text-lg">OpenCut</span> - </div> - <p className="text-sm md:text-left text-muted-foreground mb-5"> - The open source video editor that gets the job done. Simple, - powerful, and works on any platform. - </p> - <div className="flex justify-start gap-3"> - <Link - href="https://github.com/OpenCut-app/OpenCut" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiGithubLine className="h-5 w-5" /> - </Link> - <Link - href="https://x.com/OpenCutApp" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiTwitterXLine className="h-5 w-5" /> - </Link> - <Link - href="https://discord.com/invite/Mu3acKZvCp" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiDiscordFill className="h-5 w-5" /> - </Link> - </div> - </div> + return ( + <motion.footer + className="bg-background border-t" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.8, duration: 0.8 }} + > + <div className="max-w-5xl mx-auto px-8 py-10"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8"> + {/* Brand Section */} + <div className="md:col-span-1 max-w-sm"> + <div className="flex justify-start items-center gap-2 mb-4"> + <Image src="/logo.svg" alt="OpenCut" width={24} height={24} /> + <span className="font-bold text-lg">OpenCut</span> + </div> + <p className="text-sm md:text-left text-muted-foreground mb-5"> + The open source video editor that gets the job done. Simple, + powerful, and works on any platform. + </p> + <div className="flex justify-start gap-3"> + <Link + href="https://github.com/OpenCut-app/OpenCut" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + <RiGithubLine className="h-5 w-5" /> + </Link> + <Link + href="https://x.com/OpenCutApp" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + <RiTwitterXLine className="h-5 w-5" /> + </Link> + <Link + href="https://discord.com/invite/Mu3acKZvCp" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + <RiDiscordFill className="h-5 w-5" /> + </Link> + </div> + </div> - <div className="flex gap-12 justify-start items-start py-2"> - <div> - <h3 className="font-semibold text-foreground mb-4">Resources</h3> - <ul className="space-y-2 text-sm"> - <li> - <Link - href="/roadmap" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Roadmap - </Link> - </li> - <li> - <Link - href="/privacy" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Privacy policy - </Link> - </li> - <li> - <Link - href="/terms" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Terms of use - </Link> - </li> - </ul> - </div> + <div className="flex gap-12 justify-start items-start py-2"> + <div> + <h3 className="font-semibold text-foreground mb-4">Resources</h3> + <ul className="space-y-2 text-sm"> + <li> + <Link + href="/roadmap" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Roadmap + </Link> + </li> + <li> + <Link + href="/privacy" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Privacy policy + </Link> + </li> + <li> + <Link + href="/terms" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Terms of use + </Link> + </li> + </ul> + </div> - {/* Company Links */} - <div> - <h3 className="font-semibold text-foreground mb-4">Company</h3> - <ul className="space-y-2 text-sm"> - <li> - <Link - href="/contributors" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Contributors - </Link> - </li> - <li> - <Link - href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - About - </Link> - </li> - </ul> - </div> - </div> - </div> + {/* Company Links */} + <div> + <h3 className="font-semibold text-foreground mb-4">Company</h3> + <ul className="space-y-2 text-sm"> + <li> + <Link + href="/contributors" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Contributors + </Link> + </li> + <li> + <Link + href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + About + </Link> + </li> + </ul> + </div> + </div> + </div> - {/* Bottom Section */} - <div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4"> - <div className="flex items-center gap-4 text-sm text-muted-foreground"> - <span>© 2025 OpenCut, All Rights Reserved</span> - </div> - </div> - </div> - </motion.footer> - ); + {/* Bottom Section */} + <div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4"> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <span>© 2025 OpenCut, All Rights Reserved</span> + </div> + </div> + </div> + </motion.footer> + ); } diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx index f27eb7fc..c87803f7 100644 --- a/apps/web/src/components/header-base.tsx +++ b/apps/web/src/components/header-base.tsx @@ -1,41 +1,41 @@ -"use client";
-
-import { cn } from "@/lib/utils";
-import { ReactNode } from "react";
-
-interface HeaderBaseProps {
- leftContent?: ReactNode;
- centerContent?: ReactNode;
- rightContent?: ReactNode;
- className?: string;
- children?: ReactNode;
-}
-
-export function HeaderBase({
- leftContent,
- centerContent,
- rightContent,
- className,
- children,
-}: HeaderBaseProps) {
- // If children is provided, render it directly without the grid layout
- if (children) {
- return (
- <header className={cn("px-6 h-16 flex items-center", className)}>
- {children}
- </header>
- );
- }
-
- return (
- <header
- className={cn("px-6 h-14 flex justify-between items-center", className)}
- >
- {leftContent && <div className="flex items-center">{leftContent}</div>}
- {centerContent && (
- <div className="flex items-center">{centerContent}</div>
- )}
- {rightContent && <div className="flex items-center">{rightContent}</div>}
- </header>
- );
-}
+"use client"; + +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +interface HeaderBaseProps { + leftContent?: ReactNode; + centerContent?: ReactNode; + rightContent?: ReactNode; + className?: string; + children?: ReactNode; +} + +export function HeaderBase({ + leftContent, + centerContent, + rightContent, + className, + children, +}: HeaderBaseProps) { + // If children is provided, render it directly without the grid layout + if (children) { + return ( + <header className={cn("px-6 h-16 flex items-center", className)}> + {children} + </header> + ); + } + + return ( + <header + className={cn("px-6 h-14 flex justify-between items-center", className)} + > + {leftContent && <div className="flex items-center">{leftContent}</div>} + {centerContent && ( + <div className="flex items-center">{centerContent}</div> + )} + {rightContent && <div className="flex items-center">{rightContent}</div>} + </header> + ); +} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index bfe314d6..a52ca4c7 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,70 +1,70 @@ -"use client";
-
-import Link from "next/link";
-import { Button } from "./ui/button";
-import { ArrowRight } from "lucide-react";
-import { HeaderBase } from "./header-base";
-import { useSession } from "@opencut/auth/client";
-import { getStars } from "@/lib/fetch-github-stars";
-import { useEffect, useState } from "react";
-import Image from "next/image";
-
-export function Header() {
- const { data: session } = useSession();
- 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();
- }, []);
-
- const leftContent = (
- <Link href="/" className="flex items-center gap-3">
- <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
- <span className="text-xl font-medium hidden md:block">OpenCut</span>
- </Link>
- );
-
- const rightContent = (
- <nav className="flex items-center gap-3">
- <Link href="/contributors">
- <Button variant="text" className="text-sm p-0">
- Contributors
- </Button>
- </Link>
- {process.env.NODE_ENV === "development" ? (
- <Link href="/projects">
- <Button size="sm" className="text-sm ml-4">
- Projects
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- ) : (
- <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
- <Button size="sm" className="text-sm ml-4">
- GitHub {star}+
- <ArrowRight className="h-4 w-4" />
- </Button>
- </Link>
- )}
- </nav>
- );
-
- return (
- <div className="mx-4 md:mx-0">
- <HeaderBase
- className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
- leftContent={leftContent}
- rightContent={rightContent}
- />
- </div>
- );
-}
+"use client"; + +import Link from "next/link"; +import { Button } from "./ui/button"; +import { ArrowRight } from "lucide-react"; +import { HeaderBase } from "./header-base"; +import { useSession } from "@opencut/auth/client"; +import { getStars } from "@/lib/fetch-github-stars"; +import { useEffect, useState } from "react"; +import Image from "next/image"; + +export function Header() { + const { data: session } = useSession(); + 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(); + }, []); + + const leftContent = ( + <Link href="/" className="flex items-center gap-3"> + <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} /> + <span className="text-xl font-medium hidden md:block">OpenCut</span> + </Link> + ); + + const rightContent = ( + <nav className="flex items-center gap-3"> + <Link href="/contributors"> + <Button variant="text" className="text-sm p-0"> + Contributors + </Button> + </Link> + {process.env.NODE_ENV === "development" ? ( + <Link href="/projects"> + <Button size="sm" className="text-sm ml-4"> + Projects + <ArrowRight className="h-4 w-4" /> + </Button> + </Link> + ) : ( + <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank"> + <Button size="sm" className="text-sm ml-4"> + GitHub {star}+ + <ArrowRight className="h-4 w-4" /> + </Button> + </Link> + )} + </nav> + ); + + return ( + <div className="mx-4 md:mx-0"> + <HeaderBase + className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]" + leftContent={leftContent} + rightContent={rightContent} + /> + </div> + ); +} diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 68e8f0ff..7bff8a1f 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -1,108 +1,115 @@ export function GoogleIcon({ className }: { className?: string }) { - return ( - <svg className={className} viewBox="0 0 24 24"> - <path - fill="#4285F4" - d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" - /> - <path - fill="#34A853" - d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" - /> - <path - fill="#FBBC05" - d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" - /> - <path - fill="#EA4335" - d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" - /> - </svg> - ); + return ( + <svg className={className} viewBox="0 0 24 24"> + <path + fill="#4285F4" + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" + /> + <path + fill="#34A853" + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" + /> + <path + fill="#FBBC05" + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" + /> + <path + fill="#EA4335" + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" + /> + </svg> + ); } export function GithubIcon({ className }: { className?: string }) { - return ( - <svg - xmlns="http://www.w3.org/2000/svg" - className={className} - viewBox="0 -3.5 256 256" - preserveAspectRatio="xMinYMin meet" - > - <g fill="currentColor"> - <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + className={className} + viewBox="0 -3.5 256 256" + preserveAspectRatio="xMinYMin meet" + > + <g fill="currentColor"> + <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> - <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> - </g> - </svg> - ); + <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> + </g> + </svg> + ); } export function VercelIcon({ className }: { className?: string }) { - return ( - <svg className={className} width="20" height="18" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor"/> - </svg> - ); + return ( + <svg + className={className} + width="20" + height="18" + viewBox="0 0 76 65" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" /> + </svg> + ); } export function BackgroundIcon({ className }: { className?: string }) { - return ( - <svg - xmlns="http://www.w3.org/2000/svg" - width="353" - height="353" - viewBox="0 0 353 353" - fill="none" - className={className} - > - <g clipPath="url(#clip0_1_3)"> - <rect - x="-241.816" - y="233.387" - width="592.187" - height="17.765" - transform="rotate(-37 -241.816 233.387)" - fill="white" - /> - <rect - x="-189.907" - y="306.804" - width="592.187" - height="17.765" - transform="rotate(-37 -189.907 306.804)" - fill="white" - /> - <rect - x="-146.928" - y="389.501" - width="592.187" - height="17.765" - transform="rotate(-37 -146.928 389.501)" - fill="white" - /> - <rect - x="-103.144" - y="477.904" - width="592.187" - height="17.765" - transform="rotate(-37 -103.144 477.904)" - fill="white" - /> - <rect - x="-57.169" - y="570.714" - width="592.187" - height="17.765" - transform="rotate(-37 -57.169 570.714)" - fill="white" - /> - </g> - <defs> - <clipPath id="clip0_1_3"> - <rect width="353" height="353" fill="white" /> - </clipPath> - </defs> - </svg> - ); -}
\ No newline at end of file + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="353" + height="353" + viewBox="0 0 353 353" + fill="none" + className={className} + > + <g clipPath="url(#clip0_1_3)"> + <rect + x="-241.816" + y="233.387" + width="592.187" + height="17.765" + transform="rotate(-37 -241.816 233.387)" + fill="white" + /> + <rect + x="-189.907" + y="306.804" + width="592.187" + height="17.765" + transform="rotate(-37 -189.907 306.804)" + fill="white" + /> + <rect + x="-146.928" + y="389.501" + width="592.187" + height="17.765" + transform="rotate(-37 -146.928 389.501)" + fill="white" + /> + <rect + x="-103.144" + y="477.904" + width="592.187" + height="17.765" + transform="rotate(-37 -103.144 477.904)" + fill="white" + /> + <rect + x="-57.169" + y="570.714" + width="592.187" + height="17.765" + transform="rotate(-37 -57.169 570.714)" + fill="white" + /> + </g> + <defs> + <clipPath id="clip0_1_3"> + <rect width="353" height="353" fill="white" /> + </clipPath> + </defs> + </svg> + ); +} diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 91cfb01a..96deab72 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -4,244 +4,258 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useState, useEffect } from "react"; import { Button } from "./ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, } from "./ui/dialog"; import { getPlatformSpecialKey } from "@/lib/utils"; import { Keyboard } from "lucide-react"; import { - useKeyboardShortcutsHelp, - KeyboardShortcut, + useKeyboardShortcutsHelp, + KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts-help"; import { useKeybindingsStore } from "@/stores/keybindings-store"; import { toast } from "sonner"; const modifier: { - [key: string]: string; + [key: string]: string; } = { - Shift: "Shift", - Alt: "Alt", - ArrowLeft: "←", - ArrowRight: "→", - ArrowUp: "↑", - ArrowDown: "↓", - Space: "Space", + Shift: "Shift", + Alt: "Alt", + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Space: "Space", }; function getKeyWithModifier(key: string) { - if (key === "Ctrl") return getPlatformSpecialKey(); - return modifier[key] || key; + if (key === "Ctrl") return getPlatformSpecialKey(); + return modifier[key] || key; } -const ShortcutItem = ({ - shortcut, - recordingKey, - onStartRecording -}: { - shortcut: KeyboardShortcut; - recordingKey: string | null; - onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void; +const ShortcutItem = ({ + shortcut, + recordingKey, + onStartRecording, +}: { + shortcut: KeyboardShortcut; + recordingKey: string | null; + onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void; }) => { - // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" - const displayKeys = shortcut.keys.filter((key: string) => { - if ( - key.includes("Cmd") && - shortcut.keys.includes(key.replace("Cmd", "Ctrl")) - ) - return false; + // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" + const displayKeys = shortcut.keys.filter((key: string) => { + if ( + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) + return false; - return true; - }); + return true; + }); - return ( - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - {shortcut.icon && ( - <div className="text-muted-foreground">{shortcut.icon}</div> - )} - <span className="text-sm">{shortcut.description}</span> - </div> - <div className="flex items-center gap-1"> - {displayKeys.map((key: string, index: number) => ( - <div key={index} className="flex items-center gap-1"> - <div className="flex items-center"> - {key.split("+").map((keyPart: string, partIndex: number) => { - const keyId = `${shortcut.id}-${index}-${partIndex}`; - return ( - <EditableShortcutKey - key={partIndex} - keyId={keyId} - originalKey={key} - shortcut={shortcut} - isRecording={recordingKey === keyId} - onStartRecording={() => onStartRecording(keyId, shortcut)} - > - {getKeyWithModifier(keyPart)} - </EditableShortcutKey> - ); - })} - </div> - {index < displayKeys.length - 1 && ( - <span className="text-xs text-muted-foreground">or</span> - )} - </div> - ))} - </div> - </div> - ); + return ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + {shortcut.icon && ( + <div className="text-muted-foreground">{shortcut.icon}</div> + )} + <span className="text-sm">{shortcut.description}</span> + </div> + <div className="flex items-center gap-1"> + {displayKeys.map((key: string, index: number) => ( + <div key={index} className="flex items-center gap-1"> + <div className="flex items-center"> + {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + <EditableShortcutKey + key={partIndex} + keyId={keyId} + originalKey={key} + shortcut={shortcut} + isRecording={recordingKey === keyId} + onStartRecording={() => onStartRecording(keyId, shortcut)} + > + {getKeyWithModifier(keyPart)} + </EditableShortcutKey> + ); + })} + </div> + {index < displayKeys.length - 1 && ( + <span className="text-xs text-muted-foreground">or</span> + )} + </div> + ))} + </div> + </div> + ); }; -const EditableShortcutKey = ({ - children, - keyId, - originalKey, - shortcut, - isRecording, - onStartRecording -}: { - children: React.ReactNode; - keyId: string; - originalKey: string; - shortcut: KeyboardShortcut; - isRecording: boolean; - onStartRecording: () => void; +const EditableShortcutKey = ({ + children, + keyId, + originalKey, + shortcut, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + keyId: string; + originalKey: string; + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: () => void; }) => { - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onStartRecording(); - }; + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; - return ( - <kbd - className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${ - isRecording - ? "border-primary bg-primary/10" - : "border-white/10 bg-black/20" - }`} - onClick={handleClick} - title={isRecording ? "Press any key combination..." : "Click to edit shortcut"} - > - {children} - </kbd> - ); + return ( + <kbd + className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${ + isRecording + ? "border-primary bg-primary/10" + : "border-white/10 bg-black/20" + }`} + onClick={handleClick} + title={ + isRecording ? "Press any key combination..." : "Click to edit shortcut" + } + > + {children} + </kbd> + ); }; export const KeyboardShortcutsHelp = () => { - const [open, setOpen] = useState(false); - const [recordingKey, setRecordingKey] = useState<string | null>(null); - const [recordingShortcut, setRecordingShortcut] = useState<KeyboardShortcut | null>(null); + const [open, setOpen] = useState(false); + const [recordingKey, setRecordingKey] = useState<string | null>(null); + const [recordingShortcut, setRecordingShortcut] = + useState<KeyboardShortcut | null>(null); - const { - updateKeybinding, - removeKeybinding, - getKeybindingString, - validateKeybinding, - getKeybindingsForAction, - } = useKeybindingsStore(); + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + } = useKeybindingsStore(); - // Get shortcuts from centralized hook - const { shortcuts } = useKeyboardShortcutsHelp(); + // Get shortcuts from centralized hook + const { shortcuts } = useKeyboardShortcutsHelp(); - const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); - useEffect(() => { - if (!recordingKey || !recordingShortcut) return; + useEffect(() => { + if (!recordingKey || !recordingShortcut) return; - const handleKeyDown = (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); - const keyString = getKeybindingString(e); - if (keyString) { - // Auto-save the new keybinding - const conflict = validateKeybinding(keyString, recordingShortcut.action); - if (conflict) { - toast.error( - `Key "${keyString}" is already bound to "${conflict.existingAction}"` - ); - setRecordingKey(null); - setRecordingShortcut(null); - return; - } + const keyString = getKeybindingString(e); + if (keyString) { + // Auto-save the new keybinding + const conflict = validateKeybinding( + keyString, + recordingShortcut.action, + ); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"`, + ); + setRecordingKey(null); + setRecordingShortcut(null); + return; + } - // Remove old keybindings for this action - const oldKeys = getKeybindingsForAction(recordingShortcut.action); - oldKeys.forEach((key) => removeKeybinding(key)); + // Remove old keybindings for this action + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + oldKeys.forEach((key) => removeKeybinding(key)); - // Add new keybinding - updateKeybinding(keyString, recordingShortcut.action); - - setRecordingKey(null); - setRecordingShortcut(null); - } - }; + // Add new keybinding + updateKeybinding(keyString, recordingShortcut.action); - const handleClickOutside = (e: MouseEvent) => { - setRecordingKey(null); - setRecordingShortcut(null); - }; + setRecordingKey(null); + setRecordingShortcut(null); + } + }; - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("click", handleClickOutside); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("click", handleClickOutside); - }; - }, [recordingKey, recordingShortcut, getKeybindingString, updateKeybinding, removeKeybinding, validateKeybinding, getKeybindingsForAction]); + const handleClickOutside = (e: MouseEvent) => { + setRecordingKey(null); + setRecordingShortcut(null); + }; - const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => { - setRecordingKey(keyId); - setRecordingShortcut(shortcut); - }; + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button variant="text" size="sm" className="gap-2"> - <Keyboard className="w-4 h-4" /> - Shortcuts - </Button> - </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Keyboard className="w-5 h-5" /> - Keyboard Shortcuts - </DialogTitle> - <DialogDescription> - Speed up your video editing workflow with these keyboard shortcuts. - Click any shortcut key to edit it. - </DialogDescription> - </DialogHeader> + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingKey, + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + ]); - <div className="space-y-6"> - {categories.map((category) => ( - <div key={category} className="flex flex-col gap-1"> - <h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium"> - {category} - </h3> - <div className="space-y-0.5"> - {shortcuts - .filter((shortcut) => shortcut.category === category) - .map((shortcut, index) => ( - <ShortcutItem - key={index} - shortcut={shortcut} - recordingKey={recordingKey} - onStartRecording={handleStartRecording} - /> - ))} - </div> - </div> - ))} - </div> - </DialogContent> - </Dialog> - ); + const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => { + setRecordingKey(keyId); + setRecordingShortcut(shortcut); + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="text" size="sm" className="gap-2"> + <Keyboard className="w-4 h-4" /> + Shortcuts + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Keyboard className="w-5 h-5" /> + Keyboard Shortcuts + </DialogTitle> + <DialogDescription> + Speed up your video editing workflow with these keyboard shortcuts. + Click any shortcut key to edit it. + </DialogDescription> + </DialogHeader> + + <div className="space-y-6"> + {categories.map((category) => ( + <div key={category} className="flex flex-col gap-1"> + <h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium"> + {category} + </h3> + <div className="space-y-0.5"> + {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut, index) => ( + <ShortcutItem + key={index} + shortcut={shortcut} + recordingKey={recordingKey} + onStartRecording={handleStartRecording} + /> + ))} + </div> + </div> + ))} + </div> + </DialogContent> + </Dialog> + ); }; diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx index 36705e90..d65fccff 100644 --- a/apps/web/src/components/landing/handlebars.tsx +++ b/apps/web/src/components/landing/handlebars.tsx @@ -4,161 +4,161 @@ import React, { useState, useRef, useEffect } from "react"; import { motion, useMotionValue, useTransform, PanInfo } from "motion/react"; interface HandlebarsProps { - children: React.ReactNode; - minWidth?: number; - maxWidth?: number; - onRangeChange?: (left: number, right: number) => void; + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + onRangeChange?: (left: number, right: number) => void; } export function Handlebars({ - children, - minWidth = 50, - maxWidth = 400, - onRangeChange, + children, + minWidth = 50, + maxWidth = 400, + onRangeChange, }: HandlebarsProps) { - const [leftHandle, setLeftHandle] = useState(0); - const [rightHandle, setRightHandle] = useState(maxWidth); - const [contentWidth, setContentWidth] = useState(maxWidth); + const [leftHandle, setLeftHandle] = useState(0); + const [rightHandle, setRightHandle] = useState(maxWidth); + const [contentWidth, setContentWidth] = useState(maxWidth); - const leftHandleX = useMotionValue(0); - const rightHandleX = useMotionValue(maxWidth); + const leftHandleX = useMotionValue(0); + const rightHandleX = useMotionValue(maxWidth); - const visibleWidth = useTransform( - [leftHandleX, rightHandleX], - (values: number[]) => values[1] - values[0] - ); + const visibleWidth = useTransform( + [leftHandleX, rightHandleX], + (values: number[]) => values[1] - values[0], + ); - const contentLeft = useTransform(leftHandleX, (left: number) => -left); + const contentLeft = useTransform(leftHandleX, (left: number) => -left); - const containerRef = useRef<HTMLDivElement>(null); - const measureRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const measureRef = useRef<HTMLDivElement>(null); - useEffect(() => { - if (!measureRef.current) return; + useEffect(() => { + if (!measureRef.current) return; - const measureContent = () => { - if (measureRef.current) { - const width = measureRef.current.scrollWidth; - const paddedWidth = width + 32; - setContentWidth(paddedWidth); - setRightHandle(paddedWidth); - rightHandleX.set(paddedWidth); - } - }; + const measureContent = () => { + if (measureRef.current) { + const width = measureRef.current.scrollWidth; + const paddedWidth = width + 32; + setContentWidth(paddedWidth); + setRightHandle(paddedWidth); + rightHandleX.set(paddedWidth); + } + }; - measureContent(); - const timer = setTimeout(measureContent, 50); + measureContent(); + const timer = setTimeout(measureContent, 50); - return () => clearTimeout(timer); - }, [children, rightHandleX]); + return () => clearTimeout(timer); + }, [children, rightHandleX]); - useEffect(() => { - leftHandleX.set(leftHandle); - }, [leftHandle, leftHandleX]); + useEffect(() => { + leftHandleX.set(leftHandle); + }, [leftHandle, leftHandleX]); - useEffect(() => { - rightHandleX.set(rightHandle); - }, [rightHandle, rightHandleX]); + useEffect(() => { + rightHandleX.set(rightHandle); + }, [rightHandle, rightHandleX]); - useEffect(() => { - onRangeChange?.(leftHandle, rightHandle); - }, [leftHandle, rightHandle, onRangeChange]); + useEffect(() => { + onRangeChange?.(leftHandle, rightHandle); + }, [leftHandle, rightHandle, onRangeChange]); - const handleLeftDrag = (event: any, info: PanInfo) => { - const newLeft = Math.max( - 0, - Math.min(leftHandle + info.offset.x, rightHandle - minWidth) - ); - setLeftHandle(newLeft); - }; + const handleLeftDrag = (event: any, info: PanInfo) => { + const newLeft = Math.max( + 0, + Math.min(leftHandle + info.offset.x, rightHandle - minWidth), + ); + setLeftHandle(newLeft); + }; - const handleRightDrag = (event: any, info: PanInfo) => { - const newRight = Math.max( - leftHandle + minWidth, - Math.min(contentWidth, rightHandle + info.offset.x) - ); - setRightHandle(newRight); - }; + const handleRightDrag = (event: any, info: PanInfo) => { + const newRight = Math.max( + leftHandle + minWidth, + Math.min(contentWidth, rightHandle + info.offset.x), + ); + setRightHandle(newRight); + }; - return ( - <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> - <div - ref={measureRef} - className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]" - > - {children} - </div> + return ( + <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> + <div + ref={measureRef} + className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]" + > + {children} + </div> - <div - ref={containerRef} - className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2" - style={{ width: contentWidth }} - > - <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between"> - <motion.div - className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" - style={{ - position: "absolute", - x: leftHandleX, - left: 0, - zIndex: 10, - }} - drag="x" - dragConstraints={{ left: 0, right: rightHandle - minWidth }} - dragElastic={0} - dragMomentum={false} - onDrag={handleLeftDrag} - whileHover={{ scale: 1.05 }} - whileDrag={{ scale: 1.1 }} - transition={{ type: "spring", stiffness: 400, damping: 30 }} - > - <div className="w-2 h-8 rounded-full bg-yellow-500"></div> - </motion.div> + <div + ref={containerRef} + className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2" + style={{ width: contentWidth }} + > + <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between"> + <motion.div + className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + style={{ + position: "absolute", + x: leftHandleX, + left: 0, + zIndex: 10, + }} + drag="x" + dragConstraints={{ left: 0, right: rightHandle - minWidth }} + dragElastic={0} + dragMomentum={false} + onDrag={handleLeftDrag} + whileHover={{ scale: 1.05 }} + whileDrag={{ scale: 1.1 }} + transition={{ type: "spring", stiffness: 400, damping: 30 }} + > + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + </motion.div> - <motion.div - className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" - style={{ - position: "absolute", - x: rightHandleX, - left: -30, - zIndex: 10, - }} - drag="x" - dragConstraints={{ - left: leftHandle + minWidth, - right: contentWidth, - }} - dragElastic={0} - dragMomentum={false} - onDrag={handleRightDrag} - whileHover={{ scale: 1.05 }} - whileDrag={{ scale: 1.1 }} - transition={{ type: "spring", stiffness: 400, damping: 30 }} - > - <div className="w-2 h-8 rounded-full bg-yellow-500"></div> - </motion.div> - </div> + <motion.div + className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + style={{ + position: "absolute", + x: rightHandleX, + left: -30, + zIndex: 10, + }} + drag="x" + dragConstraints={{ + left: leftHandle + minWidth, + right: contentWidth, + }} + dragElastic={0} + dragMomentum={false} + onDrag={handleRightDrag} + whileHover={{ scale: 1.05 }} + whileDrag={{ scale: 1.1 }} + transition={{ type: "spring", stiffness: 400, damping: 30 }} + > + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + </motion.div> + </div> - <motion.div - className="relative overflow-hidden rounded-2xl" - style={{ - width: visibleWidth, - x: leftHandleX, - height: "100%", - }} - > - <motion.div - className="w-full h-full flex items-center justify-center px-4" - style={{ - x: contentLeft, - width: contentWidth, - whiteSpace: "nowrap", - }} - > - {children} - </motion.div> - </motion.div> - </div> - </div> - ); -}; + <motion.div + className="relative overflow-hidden rounded-2xl" + style={{ + width: visibleWidth, + x: leftHandleX, + height: "100%", + }} + > + <motion.div + className="w-full h-full flex items-center justify-center px-4" + style={{ + x: contentLeft, + width: contentWidth, + whiteSpace: "nowrap", + }} + > + {children} + </motion.div> + </motion.div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index d5fe38d1..4030c438 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -13,184 +13,184 @@ import Image from "next/image"; import { Handlebars } from "./handlebars"; export function Hero() { - const [email, setEmail] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [csrfToken, setCsrfToken] = useState<string | null>(null); + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [csrfToken, setCsrfToken] = useState<string | null>(null); - useEffect(() => { - let isMounted = true; - fetch("/api/waitlist/token", { - credentials: "include", - }) - .then((res) => res.json()) - .then((data) => { - if (isMounted && data.token) { - setCsrfToken(data.token); - } - }) - .catch((err) => { - console.error("Failed to fetch CSRF token:", err); - if (isMounted) { - toast.error("Security initialization failed", { - description: "Please refresh the page to continue.", - }); - } - }); - }, []); + useEffect(() => { + let isMounted = true; + fetch("/api/waitlist/token", { + credentials: "include", + }) + .then((res) => res.json()) + .then((data) => { + if (isMounted && data.token) { + setCsrfToken(data.token); + } + }) + .catch((err) => { + console.error("Failed to fetch CSRF token:", err); + if (isMounted) { + toast.error("Security initialization failed", { + description: "Please refresh the page to continue.", + }); + } + }); + }, []); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); - if (!email.trim()) { - toast.error("Email required", { - description: "Please enter your email address.", - }); - return; - } + if (!email.trim()) { + toast.error("Email required", { + description: "Please enter your email address.", + }); + return; + } - if (!csrfToken) { - toast.error("Security error", { - description: "Please refresh the page and try again.", - }); - return; - } + if (!csrfToken) { + toast.error("Security error", { + description: "Please refresh the page and try again.", + }); + return; + } - setIsSubmitting(true); + setIsSubmitting(true); - try { - const response = await fetch("/api/waitlist", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - body: JSON.stringify({ email: email.trim() }), - }); + try { + const response = await fetch("/api/waitlist", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + body: JSON.stringify({ email: email.trim() }), + }); - const data = (await response.json()) as { error: string }; + const data = (await response.json()) as { error: string }; - if (response.ok) { - toast.success("Welcome to the waitlist! 🎉", { - description: "You'll be notified when we launch.", - }); - setEmail(""); + if (response.ok) { + toast.success("Welcome to the waitlist! 🎉", { + description: "You'll be notified when we launch.", + }); + setEmail(""); - fetch("/api/waitlist/token", { credentials: "include" }) - .then((res) => res.json()) - .then((data) => { - if (data.token) setCsrfToken(data.token); - }) - .catch((err) => { - console.error("Failed to refresh CSRF token:", err); - }); - } else { - toast.error("Oops!", { - description: - (data as { error: string }).error || - "Something went wrong. Please try again.", - }); - } - } catch (error) { - toast.error("Network error", { - description: "Please check your connection and try again.", - }); - } finally { - setIsSubmitting(false); - } - }; + fetch("/api/waitlist/token", { credentials: "include" }) + .then((res) => res.json()) + .then((data) => { + if (data.token) setCsrfToken(data.token); + }) + .catch((err) => { + console.error("Failed to refresh CSRF token:", err); + }); + } else { + toast.error("Oops!", { + description: + (data as { error: string }).error || + "Something went wrong. Please try again.", + }); + } + } catch (error) { + toast.error("Network error", { + description: "Please check your connection and try again.", + }); + } finally { + setIsSubmitting(false); + } + }; - return ( - <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> - <Image - className="absolute top-0 left-0 -z-50 size-full object-cover" - src="/landing-page-bg.png" - height={1903.5} - width={1269} - alt="landing-page.bg" - /> - <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 1 }} - className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" - > - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.6, duration: 0.8 }} - className="mb-8 flex justify-center" - > - <SponsorButton - href="https://vercel.com/?utm_source=opencut" - logo={VercelIcon} - companyName="Vercel" - /> - </motion.div> - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.2, duration: 0.8 }} - className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" - > - <h1>The Open Source</h1> - <Handlebars>Video Editor</Handlebars> - </motion.div> + return ( + <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> + <Image + className="absolute top-0 left-0 -z-50 size-full object-cover" + src="/landing-page-bg.png" + height={1903.5} + width={1269} + alt="landing-page.bg" + /> + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 1 }} + className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" + > + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.6, duration: 0.8 }} + className="mb-8 flex justify-center" + > + <SponsorButton + href="https://vercel.com/?utm_source=opencut" + logo={VercelIcon} + companyName="Vercel" + /> + </motion.div> + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.2, duration: 0.8 }} + className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" + > + <h1>The Open Source</h1> + <Handlebars>Video Editor</Handlebars> + </motion.div> - <motion.p - className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.4, duration: 0.8 }} - > - A simple but powerful video editor that gets the job done. Works on - any platform. - </motion.p> + <motion.p + className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.4, duration: 0.8 }} + > + A simple but powerful video editor that gets the job done. Works on + any platform. + </motion.p> - <motion.div - className="mt-12 flex gap-8 justify-center" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.6, duration: 0.8 }} - > - <form - onSubmit={handleSubmit} - className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" - > - <div className="relative w-full"> - <Input - type="email" - placeholder="Enter your email" - className="h-11 text-base flex-1" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isSubmitting || !csrfToken} - required - /> - </div> - <Button - type="submit" - size="lg" - className="px-6 h-11 text-base !bg-foreground" - disabled={isSubmitting || !csrfToken} - > - <span className="relative z-10"> - {isSubmitting ? "Joining..." : "Join waitlist"} - </span> - <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> - </Button> - </form> - </motion.div> - <motion.div - initial={{ opacity: 0, y: 10 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.8, duration: 0.6 }} - className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" - > - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> - <span>50k+ people already joined</span> - </motion.div> - </motion.div> - </div> - ); + <motion.div + className="mt-12 flex gap-8 justify-center" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.6, duration: 0.8 }} + > + <form + onSubmit={handleSubmit} + className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" + > + <div className="relative w-full"> + <Input + type="email" + placeholder="Enter your email" + className="h-11 text-base flex-1" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isSubmitting || !csrfToken} + required + /> + </div> + <Button + type="submit" + size="lg" + className="px-6 h-11 text-base !bg-foreground" + disabled={isSubmitting || !csrfToken} + > + <span className="relative z-10"> + {isSubmitting ? "Joining..." : "Join waitlist"} + </span> + <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> + </Button> + </form> + </motion.div> + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.8, duration: 0.6 }} + className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" + > + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> + <span>50k+ people already joined</span> + </motion.div> + </motion.div> + </div> + ); } diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index a2943677..c7144a97 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -1,29 +1,29 @@ -"use client";
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "./ui/dialog";
-
-interface OnboardingProps {
- isOpen: boolean;
- onClose: () => void;
-}
-
-export function Onboarding({ isOpen, onClose }: OnboardingProps) {
- return (
- <Dialog open={isOpen} onOpenChange={onClose}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Welcome to Clipture</DialogTitle>
- <DialogDescription>
- Let's get you started with the basics.
- </DialogDescription>
- </DialogHeader>
- </DialogContent>
- </Dialog>
- );
-}
+"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +interface OnboardingProps { + isOpen: boolean; + onClose: () => void; +} + +export function Onboarding({ isOpen, onClose }: OnboardingProps) { + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent> + <DialogHeader> + <DialogTitle>Welcome to Clipture</DialogTitle> + <DialogDescription> + Let's get you started with the basics. + </DialogDescription> + </DialogHeader> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index d84bad71..7795c8ed 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -1,73 +1,73 @@ -import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { useState } from "react";
-
-export function RenameProjectDialog({
- isOpen,
- onOpenChange,
- onConfirm,
- projectName,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: (name: string) => void;
- projectName: string;
-}) {
- const [name, setName] = useState(projectName);
-
- // Reset the name when dialog opens - this is better UX than syncing with every prop change
- const handleOpenChange = (open: boolean) => {
- if (open) {
- setName(projectName);
- }
- onOpenChange(open);
- };
-
- return (
- <Dialog open={isOpen} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Rename Project</DialogTitle>
- <DialogDescription>
- Enter a new name for your project.
- </DialogDescription>
- </DialogHeader>
-
- <Input
- value={name}
- onChange={(e) => setName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- onConfirm(name);
- }
- }}
- placeholder="Enter a new name"
- className="mt-4"
- />
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onOpenChange(false);
- }}
- >
- Cancel
- </Button>
- <Button onClick={() => onConfirm(name)}>Rename</Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
+import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; + +export function RenameProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectName, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; + projectName: string; +}) { + const [name, setName] = useState(projectName); + + // Reset the name when dialog opens - this is better UX than syncing with every prop change + const handleOpenChange = (open: boolean) => { + if (open) { + setName(projectName); + } + onOpenChange(open); + }; + + return ( + <Dialog open={isOpen} onOpenChange={handleOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>Rename Project</DialogTitle> + <DialogDescription> + Enter a new name for your project. + </DialogDescription> + </DialogHeader> + + <Input + value={name} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="Enter a new name" + className="mt-4" + /> + + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} + > + Cancel + </Button> + <Button onClick={() => onConfirm(name)}>Rename</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx index 48423b5c..889b9b6f 100644 --- a/apps/web/src/components/storage-provider.tsx +++ b/apps/web/src/components/storage-provider.tsx @@ -1,80 +1,80 @@ -"use client";
-
-import { createContext, useContext, useEffect, useState } from "react";
-import { useProjectStore } from "@/stores/project-store";
-import { useMediaStore } from "@/stores/media-store";
-import { storageService } from "@/lib/storage/storage-service";
-import { toast } from "sonner";
-
-interface StorageContextType {
- isInitialized: boolean;
- isLoading: boolean;
- hasSupport: boolean;
- error: string | null;
-}
-
-const StorageContext = createContext<StorageContextType | null>(null);
-
-export function useStorage() {
- const context = useContext(StorageContext);
- if (!context) {
- throw new Error("useStorage must be used within StorageProvider");
- }
- return context;
-}
-
-interface StorageProviderProps {
- children: React.ReactNode;
-}
-
-export function StorageProvider({ children }: StorageProviderProps) {
- const [status, setStatus] = useState<StorageContextType>({
- isInitialized: false,
- isLoading: true,
- hasSupport: false,
- error: null,
- });
-
- const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
-
- useEffect(() => {
- const initializeStorage = async () => {
- setStatus((prev) => ({ ...prev, isLoading: true }));
-
- try {
- // Check browser support
- const hasSupport = storageService.isFullySupported();
-
- if (!hasSupport) {
- toast.warning(
- "Storage not fully supported. Some features may not work."
- );
- }
-
- // Load saved projects (media will be loaded when a project is loaded)
- await loadAllProjects();
-
- setStatus({
- isInitialized: true,
- isLoading: false,
- hasSupport,
- error: null,
- });
- } catch (error) {
- console.error("Failed to initialize storage:", error);
- setStatus({
- isInitialized: false,
- isLoading: false,
- hasSupport: storageService.isFullySupported(),
- error: error instanceof Error ? error.message : "Unknown error",
- });
- }
- };
-
- initializeStorage();
- }, [loadAllProjects]);
-
- return (
- <StorageContext.Provider value={status}>{children}</StorageContext.Provider>
- );
-}
+"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { useProjectStore } from "@/stores/project-store"; +import { useMediaStore } from "@/stores/media-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { toast } from "sonner"; + +interface StorageContextType { + isInitialized: boolean; + isLoading: boolean; + hasSupport: boolean; + error: string | null; +} + +const StorageContext = createContext<StorageContextType | null>(null); + +export function useStorage() { + const context = useContext(StorageContext); + if (!context) { + throw new Error("useStorage must be used within StorageProvider"); + } + return context; +} + +interface StorageProviderProps { + children: React.ReactNode; +} + +export function StorageProvider({ children }: StorageProviderProps) { + const [status, setStatus] = useState<StorageContextType>({ + isInitialized: false, + isLoading: true, + hasSupport: false, + error: null, + }); + + const loadAllProjects = useProjectStore((state) => state.loadAllProjects); + + useEffect(() => { + const initializeStorage = async () => { + setStatus((prev) => ({ ...prev, isLoading: true })); + + try { + // Check browser support + const hasSupport = storageService.isFullySupported(); + + if (!hasSupport) { + toast.warning( + "Storage not fully supported. Some features may not work.", + ); + } + + // Load saved projects (media will be loaded when a project is loaded) + await loadAllProjects(); + + setStatus({ + isInitialized: true, + isLoading: false, + hasSupport, + error: null, + }); + } catch (error) { + console.error("Failed to initialize storage:", error); + setStatus({ + isInitialized: false, + isLoading: false, + hasSupport: storageService.isFullySupported(), + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + initializeStorage(); + }, [loadAllProjects]); + + return ( + <StorageContext.Provider value={status}>{children}</StorageContext.Provider> + ); +} diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx index 68edd869..3d97d714 100644 --- a/apps/web/src/components/ui/accordion.tsx +++ b/apps/web/src/components/ui/accordion.tsx @@ -9,48 +9,48 @@ import { cn } from "../../lib/utils"; const Accordion = AccordionPrimitive.Root; const AccordionItem = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> + React.ElementRef<typeof AccordionPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> >(({ className, ...props }, ref) => ( - <AccordionPrimitive.Item - ref={ref} - className={cn("border-b", className)} - {...props} - /> + <AccordionPrimitive.Item + ref={ref} + className={cn("border-b", className)} + {...props} + /> )); AccordionItem.displayName = "AccordionItem"; const AccordionTrigger = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> + React.ElementRef<typeof AccordionPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Header className="flex"> - <AccordionPrimitive.Trigger - ref={ref} - className={cn( - "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", - className - )} - {...props} - > - {children} - <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> - </AccordionPrimitive.Trigger> - </AccordionPrimitive.Header> + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", + className, + )} + {...props} + > + {children} + <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> )); AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> + React.ElementRef<typeof AccordionPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Content - ref={ref} - className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" - {...props} - > - <div className={cn("pb-4 pt-0", className)}>{children}</div> - </AccordionPrimitive.Content> + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props} + > + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> )); AccordionContent.displayName = AccordionPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 2cce235c..5912e7b1 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -13,129 +13,129 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger; const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogOverlay = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> + React.ElementRef<typeof AlertDialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.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", - className - )} - {...props} - ref={ref} - /> + <AlertDialogPrimitive.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", + className, + )} + {...props} + ref={ref} + /> )); AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; const AlertDialogContent = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> + React.ElementRef<typeof AlertDialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> >(({ className, ...props }, ref) => ( - <AlertDialogPortal> - <AlertDialogOverlay /> - <AlertDialogPrimitive.Content - ref={ref} - className={cn( - "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className - )} - {...props} - /> - </AlertDialogPortal> + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className, + )} + {...props} + /> + </AlertDialogPortal> )); AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; const AlertDialogHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className - )} - {...props} - /> + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className, + )} + {...props} + /> ); AlertDialogHeader.displayName = "AlertDialogHeader"; const AlertDialogFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className - )} - {...props} - /> + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> ); AlertDialogFooter.displayName = "AlertDialogFooter"; const AlertDialogTitle = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> + React.ElementRef<typeof AlertDialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Title - ref={ref} - className={cn("text-lg font-semibold", className)} - {...props} - /> + <AlertDialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold", className)} + {...props} + /> )); AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; const AlertDialogDescription = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> + React.ElementRef<typeof AlertDialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <AlertDialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName; + AlertDialogPrimitive.Description.displayName; const AlertDialogAction = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Action>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> + React.ElementRef<typeof AlertDialogPrimitive.Action>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Action - ref={ref} - className={cn(buttonVariants(), className)} - {...props} - /> + <AlertDialogPrimitive.Action + ref={ref} + className={cn(buttonVariants(), className)} + {...props} + /> )); AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; const AlertDialogCancel = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Cancel>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> + React.ElementRef<typeof AlertDialogPrimitive.Cancel>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Cancel - ref={ref} - className={cn( - buttonVariants({ variant: "outline" }), - "mt-2 sm:mt-0", - className - )} - {...props} - /> + <AlertDialogPrimitive.Cancel + ref={ref} + className={cn( + buttonVariants({ variant: "outline" }), + "mt-2 sm:mt-0", + className, + )} + {...props} + /> )); AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, }; diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index cef19199..c5bc2a10 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -4,55 +4,55 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const alertVariants = cva( - "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, ); const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> >(({ className, variant, ...props }, ref) => ( - <div - ref={ref} - role="alert" - className={cn(alertVariants({ variant }), className)} - {...props} - /> + <div + ref={ref} + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> )); Alert.displayName = "Alert"; const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLHeadingElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => ( - <h5 - ref={ref} - className={cn("mb-1 font-medium leading-none tracking-tight", className)} - {...props} - /> + <h5 + ref={ref} + className={cn("mb-1 font-medium leading-none tracking-tight", className)} + {...props} + /> )); AlertTitle.displayName = "AlertTitle"; const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("text-sm [&_p]:leading-relaxed", className)} - {...props} - /> + <div + ref={ref} + className={cn("text-sm [&_p]:leading-relaxed", className)} + {...props} + /> )); AlertDescription.displayName = "AlertDescription"; diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx index 6f90bbb8..082de9df 100644 --- a/apps/web/src/components/ui/aspect-ratio.tsx +++ b/apps/web/src/components/ui/aspect-ratio.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import { AspectRatio as AspectRatioPrimitive } from "radix-ui" +import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; -const AspectRatio = AspectRatioPrimitive.Root +const AspectRatio = AspectRatioPrimitive.Root; -export { AspectRatio } +export { AspectRatio }; diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx index 774dd95c..df150105 100644 --- a/apps/web/src/components/ui/audio-player.tsx +++ b/apps/web/src/components/ui/audio-player.tsx @@ -1,127 +1,127 @@ -"use client";
-
-import { useRef, useEffect } from "react";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-interface AudioPlayerProps {
- src: string;
- className?: string;
- clipStartTime: number;
- trimStart: number;
- trimEnd: number;
- clipDuration: number;
- trackMuted?: boolean;
-}
-
-export function AudioPlayer({
- src,
- className = "",
- clipStartTime,
- trimStart,
- trimEnd,
- clipDuration,
- trackMuted = false,
-}: AudioPlayerProps) {
- const audioRef = useRef<HTMLAudioElement>(null);
- const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
-
- // Calculate if we're within this clip's timeline range
- const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
- const isInClipRange =
- currentTime >= clipStartTime && currentTime < clipEndTime;
-
- // Sync playback events
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio || !isInClipRange) return;
-
- const handleSeekEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const audioTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
- audio.currentTime = audioTime;
- };
-
- const handleUpdateEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const targetTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
-
- if (Math.abs(audio.currentTime - targetTime) > 0.5) {
- audio.currentTime = targetTime;
- }
- };
-
- const handleSpeed = (e: CustomEvent) => {
- audio.playbackRate = e.detail.speed;
- };
-
- window.addEventListener("playback-seek", handleSeekEvent as EventListener);
- window.addEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.addEventListener("playback-speed", handleSpeed as EventListener);
-
- return () => {
- window.removeEventListener(
- "playback-seek",
- handleSeekEvent as EventListener
- );
- window.removeEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.removeEventListener(
- "playback-speed",
- handleSpeed as EventListener
- );
- };
- }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
-
- // Sync playback state
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- if (isPlaying && isInClipRange && !trackMuted) {
- audio.play().catch(() => {});
- } else {
- audio.pause();
- }
- }, [isPlaying, isInClipRange, trackMuted]);
-
- // Sync volume and speed
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- audio.volume = volume;
- audio.muted = muted || trackMuted;
- audio.playbackRate = speed;
- }, [volume, speed, muted, trackMuted]);
-
- return (
- <audio
- ref={audioRef}
- src={src}
- className={className}
- preload="auto"
- controls={false}
- style={{ display: "none" }} // Audio elements don't need visual representation
- onContextMenu={(e) => e.preventDefault()}
- />
- );
-}
+"use client"; + +import { useRef, useEffect } from "react"; +import { usePlaybackStore } from "@/stores/playback-store"; + +interface AudioPlayerProps { + src: string; + className?: string; + clipStartTime: number; + trimStart: number; + trimEnd: number; + clipDuration: number; + trackMuted?: boolean; +} + +export function AudioPlayer({ + src, + className = "", + clipStartTime, + trimStart, + trimEnd, + clipDuration, + trackMuted = false, +}: AudioPlayerProps) { + const audioRef = useRef<HTMLAudioElement>(null); + const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); + + // Calculate if we're within this clip's timeline range + const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); + const isInClipRange = + currentTime >= clipStartTime && currentTime < clipEndTime; + + // Sync playback events + useEffect(() => { + const audio = audioRef.current; + if (!audio || !isInClipRange) return; + + const handleSeekEvent = (e: CustomEvent) => { + // Always update audio time, even if outside clip range + const timelineTime = e.detail.time; + const audioTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart, + ), + ); + audio.currentTime = audioTime; + }; + + const handleUpdateEvent = (e: CustomEvent) => { + // Always update audio time, even if outside clip range + const timelineTime = e.detail.time; + const targetTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart, + ), + ); + + if (Math.abs(audio.currentTime - targetTime) > 0.5) { + audio.currentTime = targetTime; + } + }; + + const handleSpeed = (e: CustomEvent) => { + audio.playbackRate = e.detail.speed; + }; + + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + window.addEventListener( + "playback-update", + handleUpdateEvent as EventListener, + ); + window.addEventListener("playback-speed", handleSpeed as EventListener); + + return () => { + window.removeEventListener( + "playback-seek", + handleSeekEvent as EventListener, + ); + window.removeEventListener( + "playback-update", + handleUpdateEvent as EventListener, + ); + window.removeEventListener( + "playback-speed", + handleSpeed as EventListener, + ); + }; + }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); + + // Sync playback state + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying && isInClipRange && !trackMuted) { + audio.play().catch(() => {}); + } else { + audio.pause(); + } + }, [isPlaying, isInClipRange, trackMuted]); + + // Sync volume and speed + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.volume = volume; + audio.muted = muted || trackMuted; + audio.playbackRate = speed; + }, [volume, speed, muted, trackMuted]); + + return ( + <audio + ref={audioRef} + src={src} + className={className} + preload="auto" + controls={false} + style={{ display: "none" }} // Audio elements don't need visual representation + onContextMenu={(e) => e.preventDefault()} + /> + ); +} diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index 7979c415..5c4a800b 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -6,44 +6,44 @@ import { Avatar as AvatarPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Avatar = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Root - ref={ref} - className={cn( - "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", - className - )} - {...props} - /> + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className, + )} + {...props} + /> )); Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Image>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Image - ref={ref} - className={cn("aspect-square h-full w-full", className)} - {...props} - /> + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> )); AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Fallback>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Fallback - ref={ref} - className={cn( - "flex h-full w-full items-center justify-center rounded-full bg-muted", - className - )} - {...props} - /> + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-muted", + className, + )} + {...props} + /> )); AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index e37530f4..2f9ff077 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -4,33 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, ); export interface BadgeProps - extends React.HTMLAttributes<HTMLDivElement>, - VariantProps<typeof badgeVariants> {} + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} function Badge({ className, variant, ...props }: BadgeProps) { - return ( - <div className={cn(badgeVariants({ variant }), className)} {...props} /> - ); + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ); } export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index d9df9bf5..a16bfd98 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -5,111 +5,111 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { cn } from "../../lib/utils"; const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />); Breadcrumb.displayName = "Breadcrumb"; const BreadcrumbList = React.forwardRef< - HTMLOListElement, - React.ComponentPropsWithoutRef<"ol"> + HTMLOListElement, + React.ComponentPropsWithoutRef<"ol"> >(({ className, ...props }, ref) => ( - <ol - ref={ref} - className={cn( - "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", - className - )} - {...props} - /> + <ol + ref={ref} + className={cn( + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", + className, + )} + {...props} + /> )); BreadcrumbList.displayName = "BreadcrumbList"; const BreadcrumbItem = React.forwardRef< - HTMLLIElement, - React.ComponentPropsWithoutRef<"li"> + HTMLLIElement, + React.ComponentPropsWithoutRef<"li"> >(({ className, ...props }, ref) => ( - <li - ref={ref} - className={cn("inline-flex items-center gap-1.5", className)} - {...props} - /> + <li + ref={ref} + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> )); BreadcrumbItem.displayName = "BreadcrumbItem"; const BreadcrumbLink = React.forwardRef< - HTMLAnchorElement, - React.ComponentPropsWithoutRef<"a"> & { - asChild?: boolean; - } + HTMLAnchorElement, + React.ComponentPropsWithoutRef<"a"> & { + asChild?: boolean; + } >(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; + const Comp = asChild ? SlotPrimitive.Slot : "a"; - return ( - <Comp - ref={ref} - className={cn("transition-colors hover:text-foreground", className)} - {...props} - /> - ); + return ( + <Comp + ref={ref} + className={cn("transition-colors hover:text-foreground", className)} + {...props} + /> + ); }); BreadcrumbLink.displayName = "BreadcrumbLink"; const BreadcrumbPage = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> >(({ className, ...props }, ref) => ( - <span - ref={ref} - role="link" - aria-disabled="true" - aria-current="page" - className={cn("font-normal text-foreground", className)} - {...props} - /> + <span + ref={ref} + role="link" + aria-disabled="true" + aria-current="page" + className={cn("font-normal text-foreground", className)} + {...props} + /> )); BreadcrumbPage.displayName = "BreadcrumbPage"; const BreadcrumbSeparator = ({ - children, - className, - ...props + children, + className, + ...props }: React.ComponentProps<"li">) => ( - <li - role="presentation" - aria-hidden="true" - className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} - {...props} - > - {children ?? <ChevronRight />} - </li> + <li + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> ); BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; const BreadcrumbEllipsis = ({ - className, - ...props + className, + ...props }: React.ComponentProps<"span">) => ( - <span - role="presentation" - aria-hidden="true" - className={cn("flex h-9 w-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="h-4 w-4" /> - <span className="sr-only">More</span> - </span> + <span + role="presentation" + aria-hidden="true" + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More</span> + </span> ); BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; export { - Breadcrumb, - BreadcrumbList, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbPage, - BreadcrumbSeparator, - BreadcrumbEllipsis, + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index b8b9ab2f..e063e797 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,54 +5,53 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-foreground text-background shadow hover:bg-foreground/90", - primary: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-sm px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-7 w-7", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-foreground text-background shadow hover:bg-foreground/90", + primary: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-sm px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-7 w-7", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, ); export interface ButtonProps - extends React.ButtonHTMLAttributes<HTMLButtonElement>, - VariantProps<typeof buttonVariants> { - asChild?: boolean; + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean; } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; - return ( - <Comp - className={cn(buttonVariants({ variant, size, className }))} - ref={ref} - {...props} - /> - ); - } + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ); + }, ); Button.displayName = "Button"; diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index 192643cb..81760a7f 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -10,62 +10,62 @@ import { buttonVariants } from "./button"; export type CalendarProps = React.ComponentProps<typeof DayPicker>; function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props + className, + classNames, + showOutsideDays = true, + ...props }: CalendarProps) { - return ( - <DayPicker - showOutsideDays={showOutsideDays} - className={cn("p-3", className)} - classNames={{ - months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", - month: "space-y-4", - caption: "flex justify-center pt-1 relative items-center", - caption_label: "text-sm font-medium", - nav: "space-x-1 flex items-center", - nav_button: cn( - buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" - ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-y-1", - head_row: "flex", - head_cell: - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", - row: "flex w-full mt-2", - cell: cn( - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", - props.mode === "range" - ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md" - ), - day: cn( - buttonVariants({ variant: "text" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100" - ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", - ...classNames, - }} - components={{ - IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />, - IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />, - }} - {...props} - /> - ); + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn("p-3", className)} + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: + "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: cn( + "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", + props.mode === "range" + ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md", + ), + day: cn( + buttonVariants({ variant: "text" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100", + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />, + IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />, + }} + {...props} + /> + ); } Calendar.displayName = "Calendar"; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index c5ca9d9f..a7a22245 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -3,81 +3,78 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn( - "rounded-xl border bg-card text-card-foreground", - className - )} - {...props} - /> + <div + ref={ref} + className={cn("rounded-xl border bg-card text-card-foreground", className)} + {...props} + /> )); Card.displayName = "Card"; const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex flex-col space-y-1.5 p-6", className)} - {...props} - /> + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> )); CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("font-semibold leading-none tracking-tight", className)} - {...props} - /> + <div + ref={ref} + className={cn("font-semibold leading-none tracking-tight", className)} + {...props} + /> )); CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <div + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> )); CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex items-center p-6 pt-0", className)} - {...props} - /> + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> )); CardFooter.displayName = "CardFooter"; export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, }; diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx index 3c0efdc6..9ab74471 100644 --- a/apps/web/src/components/ui/carousel.tsx +++ b/apps/web/src/components/ui/carousel.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import useEmblaCarousel, { - type UseEmblaCarouselType, + type UseEmblaCarouselType, } from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; @@ -15,248 +15,248 @@ type CarouselOptions = UseCarouselParameters[0]; type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; - orientation?: "horizontal" | "vertical"; - setApi?: (api: CarouselApi) => void; + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { - carouselRef: ReturnType<typeof useEmblaCarousel>[0]; - api: ReturnType<typeof useEmblaCarousel>[1]; - scrollPrev: () => void; - scrollNext: () => void; - canScrollPrev: boolean; - canScrollNext: boolean; + carouselRef: ReturnType<typeof useEmblaCarousel>[0]; + api: ReturnType<typeof useEmblaCarousel>[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; } & CarouselProps; const CarouselContext = React.createContext<CarouselContextProps | null>(null); function useCarousel() { - const context = React.useContext(CarouselContext); + const context = React.useContext(CarouselContext); - if (!context) { - throw new Error("useCarousel must be used within a <Carousel />"); - } + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />"); + } - return context; + return context; } const Carousel = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & CarouselProps + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & CarouselProps >( - ( - { - orientation = "horizontal", - opts, - setApi, - plugins, - className, - children, - ...props - }, - ref - ) => { - const [carouselRef, api] = useEmblaCarousel( - { - ...opts, - axis: orientation === "horizontal" ? "x" : "y", - }, - plugins - ); - const [canScrollPrev, setCanScrollPrev] = React.useState(false); - const [canScrollNext, setCanScrollNext] = React.useState(false); + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref, + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) { - return; - } + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return; + } - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }, []); + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); - const scrollPrev = React.useCallback(() => { - api?.scrollPrev(); - }, [api]); + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); - const scrollNext = React.useCallback(() => { - api?.scrollNext(); - }, [api]); + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext] - ); + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); - React.useEffect(() => { - if (!api || !setApi) { - return; - } + React.useEffect(() => { + if (!api || !setApi) { + return; + } - setApi(api); - }, [api, setApi]); + setApi(api); + }, [api, setApi]); - React.useEffect(() => { - if (!api) { - return; - } + React.useEffect(() => { + if (!api) { + return; + } - onSelect(api); - api.on("reInit", onSelect); - api.on("select", onSelect); + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); - return () => { - api?.off("select", onSelect); - }; - }, [api, onSelect]); + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); - return ( - <CarouselContext.Provider - value={{ - carouselRef, - api: api, - opts, - orientation: - orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), - scrollPrev, - scrollNext, - canScrollPrev, - canScrollNext, - }} - > - <div - ref={ref} - onKeyDownCapture={handleKeyDown} - className={cn("relative", className)} - role="region" - aria-roledescription="carousel" - {...props} - > - {children} - </div> - </CarouselContext.Provider> - ); - } + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ); + }, ); Carousel.displayName = "Carousel"; const CarouselContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { - const { carouselRef, orientation } = useCarousel(); + const { carouselRef, orientation } = useCarousel(); - return ( - <div ref={carouselRef} className="overflow-hidden"> - <div - ref={ref} - className={cn( - "flex", - orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", - className - )} - {...props} - /> - </div> - ); + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className, + )} + {...props} + /> + </div> + ); }); CarouselContent.displayName = "CarouselContent"; const CarouselItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { - const { orientation } = useCarousel(); + const { orientation } = useCarousel(); - return ( - <div - ref={ref} - role="group" - aria-roledescription="slide" - className={cn( - "min-w-0 shrink-0 grow-0 basis-full", - orientation === "horizontal" ? "pl-4" : "pt-4", - className - )} - {...props} - /> - ); + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className, + )} + {...props} + /> + ); }); CarouselItem.displayName = "CarouselItem"; const CarouselPrevious = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> + HTMLButtonElement, + React.ComponentProps<typeof Button> >(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute h-8 w-8 rounded-full", - orientation === "horizontal" - ? "-left-12 top-1/2 -translate-y-1/2" - : "-top-12 left-1/2 -translate-x-1/2 rotate-90", - className - )} - disabled={!canScrollPrev} - onClick={scrollPrev} - {...props} - > - <ArrowLeft className="h-4 w-4" /> - <span className="sr-only">Previous slide</span> - </Button> - ); + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className, + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ); }); CarouselPrevious.displayName = "CarouselPrevious"; const CarouselNext = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> + HTMLButtonElement, + React.ComponentProps<typeof Button> >(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollNext, canScrollNext } = useCarousel(); + const { orientation, scrollNext, canScrollNext } = useCarousel(); - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute h-8 w-8 rounded-full", - orientation === "horizontal" - ? "-right-12 top-1/2 -translate-y-1/2" - : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", - className - )} - disabled={!canScrollNext} - onClick={scrollNext} - {...props} - > - <ArrowRight className="h-4 w-4" /> - <span className="sr-only">Next slide</span> - </Button> - ); + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className, + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ); }); CarouselNext.displayName = "CarouselNext"; export { - type CarouselApi, - Carousel, - CarouselContent, - CarouselItem, - CarouselPrevious, - CarouselNext, + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, }; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx index a2c19116..e1b7bc35 100644 --- a/apps/web/src/components/ui/chart.tsx +++ b/apps/web/src/components/ui/chart.tsx @@ -3,9 +3,9 @@ import * as React from "react"; import * as RechartsPrimitive from "recharts"; import { - NameType, - Payload, - ValueType, + NameType, + Payload, + ValueType, } from "recharts/types/component/DefaultTooltipContent"; import { cn } from "../../lib/utils"; @@ -14,357 +14,357 @@ import { cn } from "../../lib/utils"; const THEMES = { light: "", dark: ".dark" } as const; export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record<keyof typeof THEMES, string> } - ); + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ); }; type ChartContextProps = { - config: ChartConfig; + config: ChartConfig; }; const ChartContext = React.createContext<ChartContextProps | null>(null); function useChart() { - const context = React.useContext(ChartContext); + const context = React.useContext(ChartContext); - if (!context) { - throw new Error("useChart must be used within a <ChartContainer />"); - } + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />"); + } - return context; + return context; } const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"]; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } >(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; - return ( - <ChartContext.Provider value={{ config }}> - <div - data-chart={chartId} - ref={ref} - className={cn( - "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", - className - )} - {...props} - > - <ChartStyle id={chartId} config={config} /> - <RechartsPrimitive.ResponsiveContainer> - {children} - </RechartsPrimitive.ResponsiveContainer> - </div> - </ChartContext.Provider> - ); + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className, + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ); }); ChartContainer.displayName = "Chart"; const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color - ); + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color, + ); - if (!colorConfig.length) { - return null; - } + if (!colorConfig.length) { + return null; + } - return ( - <style - dangerouslySetInnerHTML={{ - __html: Object.entries(THEMES) - .map( - ([theme, prefix]) => ` + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => ` ${prefix} [data-chart=${id}] { ${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color; - return color ? ` --color-${key}: ${color};` : null; - }) - .join("\n")} + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; + }) + .join("\n")} } -` - ) - .join("\n"), - }} - /> - ); +`, + ) + .join("\n"), + }} + /> + ); }; const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltipContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<typeof RechartsPrimitive.Tooltip> & - React.ComponentProps<"div"> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: "line" | "dot" | "dashed"; - nameKey?: string; - labelKey?: string; - } + HTMLDivElement, + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & + React.ComponentProps<"div"> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + } >( - ( - { - active, - payload, - className, - indicator = "dot", - hideLabel = false, - hideIndicator = false, - label, - labelFormatter, - labelClassName, - formatter, - color, - nameKey, - labelKey, - }, - ref - ) => { - const { config } = useChart(); + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref, + ) => { + const { config } = useChart(); - const tooltipLabel = React.useMemo(() => { - if (hideLabel || !payload?.length) { - return null; - } + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null; + } - const [item] = payload; - const key = `${labelKey || item.dataKey || item.name || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const value = - !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label - : itemConfig?.label; + const [item] = payload; + const key = `${labelKey || item.dataKey || item.name || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label; - if (labelFormatter) { - return ( - <div className={cn("font-medium", labelClassName)}> - {labelFormatter(value, payload)} - </div> - ); - } + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ); + } - if (!value) { - return null; - } + if (!value) { + return null; + } - return <div className={cn("font-medium", labelClassName)}>{value}</div>; - }, [ - label, - labelFormatter, - payload, - hideLabel, - labelClassName, - config, - labelKey, - ]); + return <div className={cn("font-medium", labelClassName)}>{value}</div>; + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]); - if (!active || !payload?.length) { - return null; - } + if (!active || !payload?.length) { + return null; + } - const nestLabel = payload.length === 1 && indicator !== "dot"; + const nestLabel = payload.length === 1 && indicator !== "dot"; - return ( - <div - ref={ref} - className={cn( - "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", - className - )} - > - {!nestLabel ? tooltipLabel : null} - <div className="grid gap-1.5"> - {payload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload.fill || item.color; + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className, + )} + > + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload.fill || item.color; - return ( - <div - key={item.dataKey} - className={cn( - "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", - indicator === "dot" && "items-center" - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) - ) : ( - <> - {itemConfig?.icon ? ( - <itemConfig.icon /> - ) : ( - !hideIndicator && ( - <div - className={cn( - "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", - { - "h-2.5 w-2.5": indicator === "dot", - "w-1": indicator === "line", - "w-0 border-[1.5px] border-dashed bg-transparent": - indicator === "dashed", - "my-0.5": nestLabel && indicator === "dashed", - } - )} - style={ - { - "--color-bg": indicatorColor, - "--color-border": indicatorColor, - } as React.CSSProperties - } - /> - ) - )} - <div - className={cn( - "flex flex-1 justify-between leading-none", - nestLabel ? "items-end" : "items-center" - )} - > - <div className="grid gap-1.5"> - {nestLabel ? tooltipLabel : null} - <span className="text-muted-foreground"> - {itemConfig?.label || item.name} - </span> - </div> - {item.value && ( - <span className="font-mono font-medium tabular-nums text-foreground"> - {item.value.toLocaleString()} - </span> - )} - </div> - </> - )} - </div> - ); - })} - </div> - </div> - ); - } + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center", + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + }, + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center", + )} + > + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ); + })} + </div> + </div> + ); + }, ); ChartTooltipContent.displayName = "ChartTooltip"; const ChartLegend = RechartsPrimitive.Legend; const ChartLegendContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & - Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { - hideIcon?: boolean; - nameKey?: string; - } + HTMLDivElement, + React.ComponentProps<"div"> & + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { + hideIcon?: boolean; + nameKey?: string; + } >( - ( - { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, - ref - ) => { - const { config } = useChart(); + ( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref, + ) => { + const { config } = useChart(); - if (!payload?.length) { - return null; - } + if (!payload?.length) { + return null; + } - return ( - <div - ref={ref} - className={cn( - "flex items-center justify-center gap-4", - verticalAlign === "top" ? "pb-3" : "pt-3", - className - )} - > - {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className, + )} + > + {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); - return ( - <div - key={item.value} - className={cn( - "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" - )} - > - {itemConfig?.icon && !hideIcon ? ( - <itemConfig.icon /> - ) : ( - <div - className="h-2 w-2 shrink-0 rounded-[2px]" - style={{ - backgroundColor: item.color, - }} - /> - )} - {itemConfig?.label} - </div> - ); - })} - </div> - ); - } + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", + )} + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ); + })} + </div> + ); + }, ); ChartLegendContent.displayName = "ChartLegend"; // Helper to extract item config from a payload. function getPayloadConfigFromPayload( - config: ChartConfig, - payload: unknown, - key: string + config: ChartConfig, + payload: unknown, + key: string, ) { - if (typeof payload !== "object" || payload === null) { - return undefined; - } + if (typeof payload !== "object" || payload === null) { + return undefined; + } - const payloadPayload = - "payload" in payload && - typeof payload.payload === "object" && - payload.payload !== null - ? payload.payload - : undefined; + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined; - let configLabelKey: string = key; + let configLabelKey: string = key; - if ( - key in payload && - typeof payload[key as keyof typeof payload] === "string" - ) { - configLabelKey = payload[key as keyof typeof payload] as string; - } else if ( - payloadPayload && - key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" - ) { - configLabelKey = payloadPayload[ - key as keyof typeof payloadPayload - ] as string; - } + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string; + } - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config]; + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config]; } export { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, - ChartStyle, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, }; diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index 2b74bc52..c7f41c54 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -7,23 +7,23 @@ import { Check } from "lucide-react"; import { cn } from "../../lib/utils"; const Checkbox = React.forwardRef< - React.ElementRef<typeof CheckboxPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> + React.ElementRef<typeof CheckboxPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> >(({ className, ...props }, ref) => ( - <CheckboxPrimitive.Root - ref={ref} - className={cn( - "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", - className - )} - {...props} - > - <CheckboxPrimitive.Indicator - className={cn("flex items-center justify-center text-current")} - > - <Check className="h-4 w-4" /> - </CheckboxPrimitive.Indicator> - </CheckboxPrimitive.Root> + <CheckboxPrimitive.Root + ref={ref} + className={cn( + "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + className, + )} + {...props} + > + <CheckboxPrimitive.Indicator + className={cn("flex items-center justify-center text-current")} + > + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> )); Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index 6642d604..acb56414 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -1,11 +1,11 @@ -"use client" +"use client"; -import { Collapsible as CollapsiblePrimitive } from "radix-ui" +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; -const Collapsible = CollapsiblePrimitive.Root +const Collapsible = CollapsiblePrimitive.Root; -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 11b27041..2bd5567e 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -9,145 +9,145 @@ import { cn } from "../../lib/utils"; import { Dialog, DialogContent } from "./dialog"; const Command = React.forwardRef< - React.ElementRef<typeof CommandPrimitive>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive> + React.ElementRef<typeof CommandPrimitive>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive> >(({ className, ...props }, ref) => ( - <CommandPrimitive - ref={ref} - className={cn( - "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", - className - )} - {...props} - /> + <CommandPrimitive + ref={ref} + className={cn( + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", + className, + )} + {...props} + /> )); Command.displayName = CommandPrimitive.displayName; const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - <Dialog {...props}> - <DialogContent className="overflow-hidden p-0"> - <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> - {children} - </Command> - </DialogContent> - </Dialog> - ); + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0"> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> + {children} + </Command> + </DialogContent> + </Dialog> + ); }; const CommandInput = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Input>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> + React.ElementRef<typeof CommandPrimitive.Input>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> >(({ className, ...props }, ref) => ( - <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> - <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> - <CommandPrimitive.Input - ref={ref} - className={cn( - "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", - className - )} - {...props} - /> - </div> + <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={ref} + className={cn( + "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + /> + </div> )); CommandInput.displayName = CommandPrimitive.Input.displayName; const CommandList = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.List>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> + React.ElementRef<typeof CommandPrimitive.List>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> >(({ className, ...props }, ref) => ( - <CommandPrimitive.List - ref={ref} - className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} - {...props} - /> + <CommandPrimitive.List + ref={ref} + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} + {...props} + /> )); CommandList.displayName = CommandPrimitive.List.displayName; const CommandEmpty = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Empty>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> + React.ElementRef<typeof CommandPrimitive.Empty>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> >((props, ref) => ( - <CommandPrimitive.Empty - ref={ref} - className="py-6 text-center text-sm" - {...props} - /> + <CommandPrimitive.Empty + ref={ref} + className="py-6 text-center text-sm" + {...props} + /> )); CommandEmpty.displayName = CommandPrimitive.Empty.displayName; const CommandGroup = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Group>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> + React.ElementRef<typeof CommandPrimitive.Group>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> >(({ className, ...props }, ref) => ( - <CommandPrimitive.Group - ref={ref} - className={cn( - "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", - className - )} - {...props} - /> + <CommandPrimitive.Group + ref={ref} + className={cn( + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", + className, + )} + {...props} + /> )); CommandGroup.displayName = CommandPrimitive.Group.displayName; const CommandSeparator = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> + React.ElementRef<typeof CommandPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> >(({ className, ...props }, ref) => ( - <CommandPrimitive.Separator - ref={ref} - className={cn("-mx-1 h-px bg-border", className)} - {...props} - /> + <CommandPrimitive.Separator + ref={ref} + className={cn("-mx-1 h-px bg-border", className)} + {...props} + /> )); CommandSeparator.displayName = CommandPrimitive.Separator.displayName; const CommandItem = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> + React.ElementRef<typeof CommandPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> >(({ className, ...props }, ref) => ( - <CommandPrimitive.Item - ref={ref} - className={cn( - "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - className - )} - {...props} - /> + <CommandPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + className, + )} + {...props} + /> )); CommandItem.displayName = CommandPrimitive.Item.displayName; const CommandShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "ml-auto text-xs tracking-widest text-muted-foreground", - className - )} - {...props} - /> - ); + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className, + )} + {...props} + /> + ); }; CommandShortcut.displayName = "CommandShortcut"; export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, }; diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index 7aa938f6..c705b83d 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -20,196 +20,196 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const contextMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "focus:opacity-65 focus:text-accent-foreground", - destructive: "text-destructive focus:text-destructive/80", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + }, ); const ContextMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { - inset?: boolean; - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { + inset?: boolean; + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, inset, children, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.SubTrigger - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:opacity-65", - inset && "pl-8", - className - )} - {...props} - > - {children} - <ChevronRight className="ml-auto" /> - </ContextMenuPrimitive.SubTrigger> + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:opacity-65", + inset && "pl-8", + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </ContextMenuPrimitive.SubTrigger> )); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> + React.ElementRef<typeof ContextMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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} - /> + <ContextMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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} + /> )); ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> + React.ElementRef<typeof ContextMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> >(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Portal> - <ContextMenuPrimitive.Content - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", - "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} - /> - </ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", + "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} + /> + </ContextMenuPrimitive.Portal> )); ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; const ContextMenuItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { - inset?: boolean; - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { + inset?: boolean; + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, inset, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.Item - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - inset && "pl-8", - className - )} - {...props} - /> + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + inset && "pl-8", + className, + )} + {...props} + /> )); ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, children, checked, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.CheckboxItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {children} - </ContextMenuPrimitive.CheckboxItem> + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> )); ContextMenuCheckboxItem.displayName = - ContextMenuPrimitive.CheckboxItem.displayName; + ContextMenuPrimitive.CheckboxItem.displayName; const ContextMenuRadioItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, children, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.RadioItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <Circle className="h-2 w-2 fill-current" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {children} - </ContextMenuPrimitive.RadioItem> + <ContextMenuPrimitive.RadioItem + ref={ref} + className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> )); ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; const ContextMenuLabel = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { - inset?: boolean; - } + React.ElementRef<typeof ContextMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <ContextMenuPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className - )} - {...props} - /> + <ContextMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className, + )} + {...props} + /> )); ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; const ContextMenuSeparator = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> + React.ElementRef<typeof ContextMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> >(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} - {...props} - /> + <ContextMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} + {...props} + /> )); ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; const ContextMenuShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn("ml-auto text-xs tracking-widest opacity-60", className)} - {...props} - /> - ); + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ); }; ContextMenuShortcut.displayName = "ContextMenuShortcut"; export { - ContextMenu, - ContextMenuTrigger, - ContextMenuContent, - ContextMenuItem, - ContextMenuCheckboxItem, - ContextMenuRadioItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuGroup, - ContextMenuPortal, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuRadioGroup, + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index ebfd8907..4d4a8c68 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -16,118 +16,118 @@ const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Overlay - ref={ref} - className={cn( - "fixed inset-0 z-[100] 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", - className - )} - {...props} - /> + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-[100] 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", + className, + )} + {...props} + /> )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - ref={ref} - className={cn( - "fixed left-[50%] top-[50%] 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className - )} - onCloseAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - onOpenAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - {...props} - > - <ScrollArea className="max-h-[85vh]"> - <div className="p-6 space-y-4">{children}</div> - </ScrollArea> - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> - <X className="h-4 w-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </DialogPrimitive.Content> - </DialogPortal> + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className, + )} + onCloseAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + onOpenAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + {...props} + > + <ScrollArea className="max-h-[85vh]"> + <div className="p-6 space-y-4">{children}</div> + </ScrollArea> + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> )); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className - )} - {...props} - /> + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className, + )} + {...props} + /> ); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className - )} - {...props} - /> + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> ); DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Title - ref={ref} - className={cn( - "text-lg font-semibold leading-none tracking-tight", - className - )} - {...props} - /> + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> )); DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); DialogDescription.displayName = DialogPrimitive.Description.displayName; export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, }; diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index dfb4dce7..473b14df 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -3,9 +3,9 @@ import { AspectRatio } from "@/components/ui/aspect-ratio"; import { Button } from "@/components/ui/button"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import { ReactNode, useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; @@ -14,185 +14,185 @@ import { cn } from "@/lib/utils"; import { usePlaybackStore } from "@/stores/playback-store"; export interface DraggableMediaItemProps { - name: string; - preview: ReactNode; - dragData: Record<string, any>; - onDragStart?: (e: React.DragEvent) => void; - onAddToTimeline?: (currentTime: number) => void; - aspectRatio?: number; - className?: string; - showPlusOnDrag?: boolean; - showLabel?: boolean; - rounded?: boolean; + name: string; + preview: ReactNode; + dragData: Record<string, any>; + onDragStart?: (e: React.DragEvent) => void; + onAddToTimeline?: (currentTime: number) => void; + aspectRatio?: number; + className?: string; + showPlusOnDrag?: boolean; + showLabel?: boolean; + rounded?: boolean; } export function DraggableMediaItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - showPlusOnDrag = true, - showLabel = true, - rounded = true, + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + showPlusOnDrag = true, + showLabel = true, + rounded = true, }: DraggableMediaItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const currentTime = usePlaybackStore((state) => state.currentTime); + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef<HTMLDivElement>(null); + const currentTime = usePlaybackStore((state) => state.currentTime); - const handleAddToTimeline = () => { - onAddToTimeline?.(currentTime); - }; + const handleAddToTimeline = () => { + onAddToTimeline?.(currentTime); + }; - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - useEffect(() => { - if (!isDragging) return; + useEffect(() => { + if (!isDragging) return; - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; - document.addEventListener("dragover", handleDragOver); + document.addEventListener("dragover", handleDragOver); - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setDragImage(emptyImg, 0, 0); - // Set drag data - e.dataTransfer.setData( - "application/x-media-item", - JSON.stringify(dragData) - ); - e.dataTransfer.effectAllowed = "copy"; + // Set drag data + e.dataTransfer.setData( + "application/x-media-item", + JSON.stringify(dragData), + ); + e.dataTransfer.effectAllowed = "copy"; - // Set initial position and show custom drag preview - setDragPosition({ x: e.clientX, y: e.clientY }); - setIsDragging(true); + // Set initial position and show custom drag preview + setDragPosition({ x: e.clientX, y: e.clientY }); + setIsDragging(true); - onDragStart?.(e); - }; + onDragStart?.(e); + }; - const handleDragEnd = () => { - setIsDragging(false); - }; + const handleDragEnd = () => { + setIsDragging(false); + }; - return ( - <> - <div ref={dragRef} className="relative group w-28 h-28"> - <div - className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - rounded && "rounded-md", - "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding - )} - draggable={true} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {showLabel && ( - <span - className="text-[0.7rem] text-muted-foreground truncate w-full text-left" - aria-label={name} - title={name} - > - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - )} - </div> - </div> + return ( + <> + <div ref={dragRef} className="relative group w-28 h-28"> + <div + className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`} + > + <AspectRatio + ratio={aspectRatio} + className={cn( + "bg-accent relative overflow-hidden", + rounded && "rounded-md", + "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding + )} + draggable={true} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > + {preview} + {!isDragging && ( + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> + )} + </AspectRatio> + {showLabel && ( + <span + className="text-[0.7rem] text-muted-foreground truncate w-full text-left" + aria-label={name} + title={name} + > + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + </span> + )} + </div> + </div> - {/* Custom drag preview */} - {isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="fixed pointer-events-none z-[9999]" - style={{ - left: dragPosition.x - 40, // Center the preview (half of 80px) - top: dragPosition.y - 40, // Center the preview (half of 80px) - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary" - > - <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> - {preview} - </div> - {showPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body - )} - </> - ); + {/* Custom drag preview */} + {isDragging && + typeof document !== "undefined" && + createPortal( + <div + className="fixed pointer-events-none z-[9999]" + style={{ + left: dragPosition.x - 40, // Center the preview (half of 80px) + top: dragPosition.y - 40, // Center the preview (half of 80px) + }} + > + <div className="w-[80px]"> + <AspectRatio + ratio={1} + className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary" + > + <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> + {preview} + </div> + {showPlusOnDrag && ( + <PlusButton + onClick={handleAddToTimeline} + tooltipText="Add to timeline or drag to position" + /> + )} + </AspectRatio> + </div> + </div>, + document.body, + )} + </> + ); } function PlusButton({ - className, - onClick, - tooltipText, + className, + onClick, + tooltipText, }: { - className?: string; - onClick?: () => void; - tooltipText?: string; + className?: string; + onClick?: () => void; + tooltipText?: string; }) { - const button = ( - <Button - size="icon" - className={cn("absolute bottom-2 right-2 size-4", className)} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus className="!size-3" /> - </Button> - ); + const button = ( + <Button + size="icon" + className={cn("absolute bottom-2 right-2 size-4", className)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} + title={tooltipText} + > + <Plus className="!size-3" /> + </Button> + ); - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } + if (tooltipText) { + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent> + <p>{tooltipText}</p> + </TooltipContent> + </Tooltip> + ); + } - return button; + return button; } diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx index 7e065e5c..bce4f383 100644 --- a/apps/web/src/components/ui/drawer.tsx +++ b/apps/web/src/components/ui/drawer.tsx @@ -6,13 +6,13 @@ import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "../../lib/utils"; const Drawer = ({ - shouldScaleBackground = true, - ...props + shouldScaleBackground = true, + ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( - <DrawerPrimitive.Root - shouldScaleBackground={shouldScaleBackground} - {...props} - /> + <DrawerPrimitive.Root + shouldScaleBackground={shouldScaleBackground} + {...props} + /> ); Drawer.displayName = "Drawer"; @@ -23,96 +23,96 @@ const DrawerPortal = DrawerPrimitive.Portal; const DrawerClose = DrawerPrimitive.Close; const DrawerOverlay = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> + React.ElementRef<typeof DrawerPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <DrawerPrimitive.Overlay - ref={ref} - className={cn("fixed inset-0 z-50 bg-black/80", className)} - {...props} - /> + <DrawerPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} + /> )); DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; const DrawerContent = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> + React.ElementRef<typeof DrawerPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <DrawerPortal> - <DrawerOverlay /> - <DrawerPrimitive.Content - ref={ref} - className={cn( - "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", - className - )} - {...props} - > - <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> - {children} - </DrawerPrimitive.Content> - </DrawerPortal> + <DrawerPortal> + <DrawerOverlay /> + <DrawerPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", + className, + )} + {...props} + > + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> )); DrawerContent.displayName = "DrawerContent"; const DrawerHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} - {...props} - /> + <div + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} + {...props} + /> ); DrawerHeader.displayName = "DrawerHeader"; const DrawerFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("mt-auto flex flex-col gap-2 p-4", className)} - {...props} - /> + <div + className={cn("mt-auto flex flex-col gap-2 p-4", className)} + {...props} + /> ); DrawerFooter.displayName = "DrawerFooter"; const DrawerTitle = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> + React.ElementRef<typeof DrawerPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> >(({ className, ...props }, ref) => ( - <DrawerPrimitive.Title - ref={ref} - className={cn( - "text-lg font-semibold leading-none tracking-tight", - className - )} - {...props} - /> + <DrawerPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className, + )} + {...props} + /> )); DrawerTitle.displayName = DrawerPrimitive.Title.displayName; const DrawerDescription = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> + React.ElementRef<typeof DrawerPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> >(({ className, ...props }, ref) => ( - <DrawerPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <DrawerPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); DrawerDescription.displayName = DrawerPrimitive.Description.displayName; export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, }; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index d9962dd2..85642d07 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,211 +20,211 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "focus:opacity-65 focus:text-accent-foreground", - destructive: "text-destructive focus:text-destructive/80", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + }, ); const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { - inset?: boolean; - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, inset, children, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.SubTrigger - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:opacity-65", - inset && "pl-8", - className - )} - {...props} - > - {children} - <ChevronRight className="ml-auto" /> - </DropdownMenuPrimitive.SubTrigger> + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:opacity-65", + inset && "pl-8", + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </DropdownMenuPrimitive.SubTrigger> )); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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} - /> + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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} + /> )); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( - <DropdownMenuPrimitive.Portal> - <DropdownMenuPrimitive.Content - ref={ref} - sideOffset={sideOffset} - onCloseAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", - "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} - /> - </DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + onCloseAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", + "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} + /> + </DropdownMenuPrimitive.Portal> )); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { - inset?: boolean; - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, inset, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.Item - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - inset && "pl-8", - className - )} - {...props} - /> + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + inset && "pl-8", + className, + )} + {...props} + /> )); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, children, checked, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.CheckboxItem - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "pl-8 pr-2", - className - )} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.CheckboxItem> + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + "pl-8 pr-2", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> )); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, children, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.RadioItem - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "pl-8 pr-2", - className - )} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Circle className="h-2 w-2 fill-current" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.RadioItem> + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + "pl-8 pr-2", + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> )); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { - inset?: boolean; - } + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <DropdownMenuPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className - )} - {...props} - /> + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className, + )} + {...props} + /> )); DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> >(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} - {...props} - /> + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} + {...props} + /> )); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn("ml-auto text-xs tracking-widest opacity-60", className)} - {...props} - /> - ); + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ); }; DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, }; diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 9a152fd4..7705bcef 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,40 +1,40 @@ -import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
-
-interface FontPickerProps {
- defaultValue?: FontFamily;
- onValueChange?: (value: FontFamily) => void;
- className?: string;
-}
-
-export function FontPicker({
- defaultValue,
- onValueChange,
- className,
-}: FontPickerProps) {
- return (
- <Select defaultValue={defaultValue} onValueChange={onValueChange}>
- <SelectTrigger className={`w-full text-xs ${className || ""}`}>
- <SelectValue placeholder="Select a font" />
- </SelectTrigger>
- <SelectContent>
- {FONT_OPTIONS.map((font) => (
- <SelectItem
- key={font.value}
- value={font.value}
- className="text-xs"
- style={{ fontFamily: font.value }}
- >
- {font.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- );
-}
+import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants"; + +interface FontPickerProps { + defaultValue?: FontFamily; + onValueChange?: (value: FontFamily) => void; + className?: string; +} + +export function FontPicker({ + defaultValue, + onValueChange, + className, +}: FontPickerProps) { + return ( + <Select defaultValue={defaultValue} onValueChange={onValueChange}> + <SelectTrigger className={`w-full text-xs ${className || ""}`}> + <SelectValue placeholder="Select a font" /> + </SelectTrigger> + <SelectContent> + {FONT_OPTIONS.map((font) => ( + <SelectItem + key={font.value} + value={font.value} + className="text-xs" + style={{ fontFamily: font.value }} + > + {font.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx index cb4f53a9..f186d894 100644 --- a/apps/web/src/components/ui/form.tsx +++ b/apps/web/src/components/ui/form.tsx @@ -4,12 +4,12 @@ import * as React from "react"; import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"; import { - Controller, - ControllerProps, - FieldPath, - FieldValues, - FormProvider, - useFormContext, + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, } from "react-hook-form"; import { cn } from "../../lib/utils"; @@ -18,162 +18,162 @@ import { Label } from "./label"; const Form = FormProvider; type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, > = { - name: TName; + name: TName; }; const FormFieldContext = React.createContext<FormFieldContextValue>( - {} as FormFieldContextValue + {} as FormFieldContextValue, ); const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, >({ - ...props + ...props }: ControllerProps<TFieldValues, TName>) => { - return ( - <FormFieldContext.Provider value={{ name: props.name }}> - <Controller {...props} /> - </FormFieldContext.Provider> - ); + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); }; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState); + const fieldState = getFieldState(fieldContext.name, formState); - if (!fieldContext) { - throw new Error("useFormField should be used within <FormField>"); - } + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>"); + } - const { id } = itemContext; + const { id } = itemContext; - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; }; type FormItemContextValue = { - id: string; + id: string; }; const FormItemContext = React.createContext<FormItemContextValue>( - {} as FormItemContextValue + {} as FormItemContextValue, ); const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { - const id = React.useId(); + const id = React.useId(); - return ( - <FormItemContext.Provider value={{ id }}> - <div ref={ref} className={cn("space-y-2", className)} {...props} /> - </FormItemContext.Provider> - ); + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ); }); FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< - React.ElementRef<typeof LabelPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> >(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField(); + const { error, formItemId } = useFormField(); - return ( - <Label - ref={ref} - className={cn(error && "text-destructive", className)} - htmlFor={formItemId} - {...props} - /> - ); + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ); }); FormLabel.displayName = "FormLabel"; const FormControl = React.forwardRef< - React.ElementRef<typeof SlotPrimitive.Slot>, - React.ComponentPropsWithoutRef<typeof SlotPrimitive.Slot> + React.ElementRef<typeof SlotPrimitive.Slot>, + React.ComponentPropsWithoutRef<typeof SlotPrimitive.Slot> >(({ ...props }, ref) => { - const { error, formItemId, formDescriptionId, formMessageId } = - useFormField(); + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); - return ( - <SlotPrimitive.Slot - ref={ref} - id={formItemId} - aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` - } - aria-invalid={!!error} - {...props} - /> - ); + return ( + <SlotPrimitive.Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ); }); FormControl.displayName = "FormControl"; const FormDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => { - const { formDescriptionId } = useFormField(); + const { formDescriptionId } = useFormField(); - return ( - <p - ref={ref} - id={formDescriptionId} - className={cn("text-[0.8rem] text-muted-foreground", className)} - {...props} - /> - ); + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-[0.8rem] text-muted-foreground", className)} + {...props} + /> + ); }); FormDescription.displayName = "FormDescription"; const FormMessage = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> >(({ className, children, ...props }, ref) => { - const { error, formMessageId } = useFormField(); - const body = error ? String(error?.message) : children; + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message) : children; - if (!body) { - return null; - } + if (!body) { + return null; + } - return ( - <p - ref={ref} - id={formMessageId} - className={cn("text-[0.8rem] font-medium text-destructive", className)} - {...props} - > - {body} - </p> - ); + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-[0.8rem] font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ); }); FormMessage.displayName = "FormMessage"; export { - useFormField, - Form, - FormItem, - FormLabel, - FormControl, - FormDescription, - FormMessage, - FormField, + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, }; diff --git a/apps/web/src/components/ui/hover-card.tsx b/apps/web/src/components/ui/hover-card.tsx index c3958fcf..52f6c17c 100644 --- a/apps/web/src/components/ui/hover-card.tsx +++ b/apps/web/src/components/ui/hover-card.tsx @@ -10,19 +10,19 @@ const HoverCard = HoverCardPrimitive.Root; const HoverCardTrigger = HoverCardPrimitive.Trigger; const HoverCardContent = React.forwardRef< - React.ElementRef<typeof HoverCardPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> + React.ElementRef<typeof HoverCardPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - <HoverCardPrimitive.Content - ref={ref} - align={align} - sideOffset={sideOffset} - className={cn( - "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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} - /> + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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} + /> )); HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx index 31847df5..97a401b6 100644 --- a/apps/web/src/components/ui/image-timeline-treatment.tsx +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -1,102 +1,102 @@ -"use client";
-
-import { useState } from "react";
-import { cn } from "@/lib/utils";
-import type { BackgroundType } from "@/types/editor";
-
-interface ImageTimelineTreatmentProps {
- src: string;
- alt: string;
- targetAspectRatio?: number; // Default to 16:9 for video
- className?: string;
- backgroundType?: BackgroundType;
- backgroundColor?: string;
-}
-
-export function ImageTimelineTreatment({
- src,
- alt,
- targetAspectRatio = 16 / 9,
- className,
- backgroundType = "blur",
- backgroundColor = "#000000",
-}: ImageTimelineTreatmentProps) {
- const [imageLoaded, setImageLoaded] = useState(false);
- const [imageDimensions, setImageDimensions] = useState<{
- width: number;
- height: number;
- } | null>(null);
-
- const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
- const img = e.currentTarget;
- setImageDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- setImageLoaded(true);
- };
-
- const imageAspectRatio = imageDimensions
- ? imageDimensions.width / imageDimensions.height
- : 1;
-
- const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
-
- return (
- <div
- className={cn("relative overflow-hidden", className)}
- style={{ aspectRatio: targetAspectRatio }}
- >
- {/* Background Layer */}
- {needsAspectRatioTreatment && imageLoaded && (
- <>
- {backgroundType === "blur" && (
- <div className="absolute inset-0">
- <img
- src={src}
- alt=""
- className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
- aria-hidden="true"
- />
- <div className="absolute inset-0 bg-black/20" />
- </div>
- )}
-
- {backgroundType === "mirror" && (
- <div className="absolute inset-0">
- <img
- src={src}
- alt=""
- className="w-full h-full object-cover opacity-30"
- aria-hidden="true"
- />
- </div>
- )}
-
- {backgroundType === "color" && (
- <div className="absolute inset-0" style={{ backgroundColor }} />
- )}
- </>
- )}
-
- {/* Main Image Layer */}
- <div className="absolute inset-0">
- <img
- src={src}
- alt={alt}
- className="w-full h-full object-cover"
- onLoad={handleImageLoad}
- />
- </div>
-
- {/* Loading state */}
- {!imageLoaded && (
- <div className="absolute inset-0 flex items-center justify-center bg-muted/30">
- <div className="animate-pulse text-xs text-muted-foreground">
- Loading...
- </div>
- </div>
- )}
- </div>
- );
-}
+"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { BackgroundType } from "@/types/editor"; + +interface ImageTimelineTreatmentProps { + src: string; + alt: string; + targetAspectRatio?: number; // Default to 16:9 for video + className?: string; + backgroundType?: BackgroundType; + backgroundColor?: string; +} + +export function ImageTimelineTreatment({ + src, + alt, + targetAspectRatio = 16 / 9, + className, + backgroundType = "blur", + backgroundColor = "#000000", +}: ImageTimelineTreatmentProps) { + const [imageLoaded, setImageLoaded] = useState(false); + const [imageDimensions, setImageDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + + const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => { + const img = e.currentTarget; + setImageDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setImageLoaded(true); + }; + + const imageAspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : 1; + + const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio; + + return ( + <div + className={cn("relative overflow-hidden", className)} + style={{ aspectRatio: targetAspectRatio }} + > + {/* Background Layer */} + {needsAspectRatioTreatment && imageLoaded && ( + <> + {backgroundType === "blur" && ( + <div className="absolute inset-0"> + <img + src={src} + alt="" + className="w-full h-full object-cover filter blur-xl scale-110 opacity-60" + aria-hidden="true" + /> + <div className="absolute inset-0 bg-black/20" /> + </div> + )} + + {backgroundType === "mirror" && ( + <div className="absolute inset-0"> + <img + src={src} + alt="" + className="w-full h-full object-cover opacity-30" + aria-hidden="true" + /> + </div> + )} + + {backgroundType === "color" && ( + <div className="absolute inset-0" style={{ backgroundColor }} /> + )} + </> + )} + + {/* Main Image Layer */} + <div className="absolute inset-0"> + <img + src={src} + alt={alt} + className="w-full h-full object-cover" + onLoad={handleImageLoad} + /> + </div> + + {/* Loading state */} + {!imageLoaded && ( + <div className="absolute inset-0 flex items-center justify-center bg-muted/30"> + <div className="animate-pulse text-xs text-muted-foreground"> + Loading... + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx index 1bffc451..d8348449 100644 --- a/apps/web/src/components/ui/input-otp.tsx +++ b/apps/web/src/components/ui/input-otp.tsx @@ -7,68 +7,68 @@ import { Minus } from "lucide-react"; import { cn } from "../../lib/utils"; const InputOTP = React.forwardRef< - React.ElementRef<typeof OTPInput>, - React.ComponentPropsWithoutRef<typeof OTPInput> + React.ElementRef<typeof OTPInput>, + React.ComponentPropsWithoutRef<typeof OTPInput> >(({ className, containerClassName, ...props }, ref) => ( - <OTPInput - ref={ref} - containerClassName={cn( - "flex items-center justify-between gap-2 w-full has-[:disabled]:opacity-50", - containerClassName - )} - className={cn("disabled:cursor-not-allowed", className)} - {...props} - /> + <OTPInput + ref={ref} + containerClassName={cn( + "flex items-center justify-between gap-2 w-full has-[:disabled]:opacity-50", + containerClassName, + )} + className={cn("disabled:cursor-not-allowed", className)} + {...props} + /> )); InputOTP.displayName = "InputOTP"; const InputOTPGroup = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex items-center flex-1 gap-2", className)} - {...props} - /> + <div + ref={ref} + className={cn("flex items-center flex-1 gap-2", className)} + {...props} + /> )); InputOTPGroup.displayName = "InputOTPGroup"; const InputOTPSlot = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> & { index: number } + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } >(({ index, className, ...props }, ref) => { - const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; - return ( - <div - ref={ref} - className={cn( - "relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", - isActive && "z-10 ring-1 ring-ring", - className - )} - {...props} - > - {char} - {hasFakeCaret && ( - <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> - <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> - </div> - )} - </div> - ); + return ( + <div + ref={ref} + className={cn( + "relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", + isActive && "z-10 ring-1 ring-ring", + className, + )} + {...props} + > + {char} + {hasFakeCaret && ( + <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> + <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> + </div> + )} + </div> + ); }); InputOTPSlot.displayName = "InputOTPSlot"; const InputOTPSeparator = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> >(({ ...props }, ref) => ( - <div ref={ref} role="separator" {...props}> - <Minus /> - </div> + <div ref={ref} role="separator" {...props}> + <Minus /> + </div> )); InputOTPSeparator.displayName = "InputOTPSeparator"; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 8fc31a58..7bcf8e14 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -5,51 +5,51 @@ import { cn } from "../../lib/utils"; import { Button } from "./button"; interface InputProps extends React.ComponentProps<"input"> { - showPassword?: boolean; - onShowPasswordChange?: (show: boolean) => void; + showPassword?: boolean; + onShowPasswordChange?: (show: boolean) => void; } const Input = React.forwardRef<HTMLInputElement, InputProps>( - ( - { className, type, showPassword, onShowPasswordChange, value, ...props }, - ref - ) => { - const isPassword = type === "password"; - const showPasswordToggle = isPassword && onShowPasswordChange; - const inputType = isPassword && showPassword ? "text" : type; + ( + { className, type, showPassword, onShowPasswordChange, value, ...props }, + ref, + ) => { + const isPassword = type === "password"; + const showPasswordToggle = isPassword && onShowPasswordChange; + const inputType = isPassword && showPassword ? "text" : type; - return ( - <div className={showPassword ? "relative w-full" : ""}> - <input - type={inputType} - className={cn( - "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - showPasswordToggle && "pr-10", - className - )} - ref={ref} - value={value} - {...props} - /> - {showPasswordToggle && ( - <Button - type="button" - variant="text" - size="icon" - onClick={() => onShowPasswordChange?.(!showPassword)} - className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground" - aria-label={showPassword ? "Hide password" : "Show password"} - > - {showPassword ? ( - <Eye className="h-4 w-4" /> - ) : ( - <EyeOff className="h-4 w-4" /> - )} - </Button> - )} - </div> - ); - } + return ( + <div className={showPassword ? "relative w-full" : ""}> + <input + type={inputType} + className={cn( + "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + showPasswordToggle && "pr-10", + className, + )} + ref={ref} + value={value} + {...props} + /> + {showPasswordToggle && ( + <Button + type="button" + variant="text" + size="icon" + onClick={() => onShowPasswordChange?.(!showPassword)} + className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground" + aria-label={showPassword ? "Hide password" : "Show password"} + > + {showPassword ? ( + <Eye className="h-4 w-4" /> + ) : ( + <EyeOff className="h-4 w-4" /> + )} + </Button> + )} + </div> + ); + }, ); Input.displayName = "Input"; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx index 877af15b..bca55d10 100644 --- a/apps/web/src/components/ui/label.tsx +++ b/apps/web/src/components/ui/label.tsx @@ -7,19 +7,19 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", ); const Label = React.forwardRef< - React.ElementRef<typeof LabelPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & - VariantProps<typeof labelVariants> + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> >(({ className, ...props }, ref) => ( - <LabelPrimitive.Root - ref={ref} - className={cn(labelVariants(), className)} - {...props} - /> + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> )); Label.displayName = LabelPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx index 642ffa2b..03ba0caa 100644 --- a/apps/web/src/components/ui/menubar.tsx +++ b/apps/web/src/components/ui/menubar.tsx @@ -17,220 +17,220 @@ const MenubarSub = MenubarPrimitive.Sub; const MenubarRadioGroup = MenubarPrimitive.RadioGroup; const Menubar = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> + React.ElementRef<typeof MenubarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.Root - ref={ref} - className={cn( - "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", - className - )} - {...props} - /> + <MenubarPrimitive.Root + ref={ref} + className={cn( + "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", + className, + )} + {...props} + /> )); Menubar.displayName = MenubarPrimitive.Root.displayName; const MenubarTrigger = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> + React.ElementRef<typeof MenubarPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.Trigger - ref={ref} - className={cn( - "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - className - )} - {...props} - /> + <MenubarPrimitive.Trigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + className, + )} + {...props} + /> )); MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; const MenubarSubTrigger = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { - inset?: boolean; - } + React.ElementRef<typeof MenubarPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { + inset?: boolean; + } >(({ className, inset, children, ...props }, ref) => ( - <MenubarPrimitive.SubTrigger - ref={ref} - className={cn( - "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - inset && "pl-8", - className - )} - {...props} - > - {children} - <ChevronRight className="ml-auto h-4 w-4" /> - </MenubarPrimitive.SubTrigger> + <MenubarPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className, + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </MenubarPrimitive.SubTrigger> )); MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; const MenubarSubContent = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> + React.ElementRef<typeof MenubarPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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} - /> + <MenubarPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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} + /> )); MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; const MenubarContent = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> + React.ElementRef<typeof MenubarPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> >( - ( - { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, - ref - ) => ( - <MenubarPrimitive.Portal> - <MenubarPrimitive.Content - ref={ref} - align={align} - alignOffset={alignOffset} - sideOffset={sideOffset} - className={cn( - "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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} - /> - </MenubarPrimitive.Portal> - ) + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref, + ) => ( + <MenubarPrimitive.Portal> + <MenubarPrimitive.Content + ref={ref} + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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} + /> + </MenubarPrimitive.Portal> + ), ); MenubarContent.displayName = MenubarPrimitive.Content.displayName; const MenubarItem = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { - inset?: boolean; - } + React.ElementRef<typeof MenubarPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <MenubarPrimitive.Item - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - inset && "pl-8", - className - )} - {...props} - /> + <MenubarPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className, + )} + {...props} + /> )); MenubarItem.displayName = MenubarPrimitive.Item.displayName; const MenubarCheckboxItem = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> + React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> >(({ className, children, checked, ...props }, ref) => ( - <MenubarPrimitive.CheckboxItem - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <MenubarPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </MenubarPrimitive.ItemIndicator> - </span> - {children} - </MenubarPrimitive.CheckboxItem> + <MenubarPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.CheckboxItem> )); MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; const MenubarRadioItem = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> + React.ElementRef<typeof MenubarPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> >(({ className, children, ...props }, ref) => ( - <MenubarPrimitive.RadioItem - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <MenubarPrimitive.ItemIndicator> - <Circle className="h-4 w-4 fill-current" /> - </MenubarPrimitive.ItemIndicator> - </span> - {children} - </MenubarPrimitive.RadioItem> + <MenubarPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Circle className="h-4 w-4 fill-current" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.RadioItem> )); MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; const MenubarLabel = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { - inset?: boolean; - } + React.ElementRef<typeof MenubarPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <MenubarPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className - )} - {...props} - /> + <MenubarPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className, + )} + {...props} + /> )); MenubarLabel.displayName = MenubarPrimitive.Label.displayName; const MenubarSeparator = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> + React.ElementRef<typeof MenubarPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-muted", className)} - {...props} - /> + <MenubarPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> )); MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; const MenubarShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "ml-auto text-xs tracking-widest text-muted-foreground", - className - )} - {...props} - /> - ); + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className, + )} + {...props} + /> + ); }; MenubarShortcut.displayname = "MenubarShortcut"; export { - Menubar, - MenubarMenu, - MenubarTrigger, - MenubarContent, - MenubarItem, - MenubarSeparator, - MenubarLabel, - MenubarCheckboxItem, - MenubarRadioGroup, - MenubarRadioItem, - MenubarPortal, - MenubarSubContent, - MenubarSubTrigger, - MenubarGroup, - MenubarSub, - MenubarShortcut, + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, }; diff --git a/apps/web/src/components/ui/navigation-menu.tsx b/apps/web/src/components/ui/navigation-menu.tsx index b5dd3622..0844f989 100644 --- a/apps/web/src/components/ui/navigation-menu.tsx +++ b/apps/web/src/components/ui/navigation-menu.tsx @@ -6,123 +6,123 @@ import { ChevronDown } from "lucide-react"; import { cn } from "../../lib/utils"; const NavigationMenu = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> + React.ElementRef<typeof NavigationMenuPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> >(({ className, children, ...props }, ref) => ( - <NavigationMenuPrimitive.Root - ref={ref} - className={cn( - "relative z-10 flex max-w-max flex-1 items-center justify-center", - className - )} - {...props} - > - {children} - <NavigationMenuViewport /> - </NavigationMenuPrimitive.Root> + <NavigationMenuPrimitive.Root + ref={ref} + className={cn( + "relative z-10 flex max-w-max flex-1 items-center justify-center", + className, + )} + {...props} + > + {children} + <NavigationMenuViewport /> + </NavigationMenuPrimitive.Root> )); NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; const NavigationMenuList = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.List>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> + React.ElementRef<typeof NavigationMenuPrimitive.List>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> >(({ className, ...props }, ref) => ( - <NavigationMenuPrimitive.List - ref={ref} - className={cn( - "group flex flex-1 list-none items-center justify-center space-x-1", - className - )} - {...props} - /> + <NavigationMenuPrimitive.List + ref={ref} + className={cn( + "group flex flex-1 list-none items-center justify-center space-x-1", + className, + )} + {...props} + /> )); NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; const NavigationMenuItem = NavigationMenuPrimitive.Item; const navigationMenuTriggerStyle = cva( - "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50", ); const NavigationMenuTrigger = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> + React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <NavigationMenuPrimitive.Trigger - ref={ref} - className={cn(navigationMenuTriggerStyle(), "group", className)} - {...props} - > - {children}{" "} - <ChevronDown - className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" - aria-hidden="true" - /> - </NavigationMenuPrimitive.Trigger> + <NavigationMenuPrimitive.Trigger + ref={ref} + className={cn(navigationMenuTriggerStyle(), "group", className)} + {...props} + > + {children}{" "} + <ChevronDown + className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" + aria-hidden="true" + /> + </NavigationMenuPrimitive.Trigger> )); NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; const NavigationMenuContent = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> + React.ElementRef<typeof NavigationMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> >(({ className, ...props }, ref) => ( - <NavigationMenuPrimitive.Content - ref={ref} - className={cn( - "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", - className - )} - {...props} - /> + <NavigationMenuPrimitive.Content + ref={ref} + className={cn( + "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", + className, + )} + {...props} + /> )); NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; const NavigationMenuLink = NavigationMenuPrimitive.Link; const NavigationMenuViewport = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> + React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> >(({ className, ...props }, ref) => ( - <div className={cn("absolute left-0 top-full flex justify-center")}> - <NavigationMenuPrimitive.Viewport - className={cn( - "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", - className - )} - ref={ref} - {...props} - /> - </div> + <div className={cn("absolute left-0 top-full flex justify-center")}> + <NavigationMenuPrimitive.Viewport + className={cn( + "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", + className, + )} + ref={ref} + {...props} + /> + </div> )); NavigationMenuViewport.displayName = - NavigationMenuPrimitive.Viewport.displayName; + NavigationMenuPrimitive.Viewport.displayName; const NavigationMenuIndicator = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> + React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> >(({ className, ...props }, ref) => ( - <NavigationMenuPrimitive.Indicator - ref={ref} - className={cn( - "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", - className - )} - {...props} - > - <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> - </NavigationMenuPrimitive.Indicator> + <NavigationMenuPrimitive.Indicator + ref={ref} + className={cn( + "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", + className, + )} + {...props} + > + <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> + </NavigationMenuPrimitive.Indicator> )); NavigationMenuIndicator.displayName = - NavigationMenuPrimitive.Indicator.displayName; + NavigationMenuPrimitive.Indicator.displayName; export { - navigationMenuTriggerStyle, - NavigationMenu, - NavigationMenuList, - NavigationMenuItem, - NavigationMenuContent, - NavigationMenuTrigger, - NavigationMenuLink, - NavigationMenuIndicator, - NavigationMenuViewport, + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, }; diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx index 61766cec..ad132ee5 100644 --- a/apps/web/src/components/ui/pagination.tsx +++ b/apps/web/src/components/ui/pagination.tsx @@ -5,113 +5,113 @@ import { cn } from "../../lib/utils"; import { ButtonProps, buttonVariants } from "./button"; const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( - <nav - role="navigation" - aria-label="pagination" - className={cn("mx-auto flex w-full justify-center", className)} - {...props} - /> + <nav + role="navigation" + aria-label="pagination" + className={cn("mx-auto flex w-full justify-center", className)} + {...props} + /> ); Pagination.displayName = "Pagination"; const PaginationContent = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> + HTMLUListElement, + React.ComponentProps<"ul"> >(({ className, ...props }, ref) => ( - <ul - ref={ref} - className={cn("flex flex-row items-center gap-1", className)} - {...props} - /> + <ul + ref={ref} + className={cn("flex flex-row items-center gap-1", className)} + {...props} + /> )); PaginationContent.displayName = "PaginationContent"; const PaginationItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> + HTMLLIElement, + React.ComponentProps<"li"> >(({ className, ...props }, ref) => ( - <li ref={ref} className={cn("", className)} {...props} /> + <li ref={ref} className={cn("", className)} {...props} /> )); PaginationItem.displayName = "PaginationItem"; type PaginationLinkProps = { - isActive?: boolean; + isActive?: boolean; } & Pick<ButtonProps, "size"> & - React.ComponentProps<"a">; + React.ComponentProps<"a">; const PaginationLink = ({ - className, - isActive, - size = "icon", - ...props + className, + isActive, + size = "icon", + ...props }: PaginationLinkProps) => ( - <a - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ - variant: isActive ? "outline" : "text", - size, - }), - className - )} - {...props} - /> + <a + aria-current={isActive ? "page" : undefined} + className={cn( + buttonVariants({ + variant: isActive ? "outline" : "text", + size, + }), + className, + )} + {...props} + /> ); PaginationLink.displayName = "PaginationLink"; const PaginationPrevious = ({ - className, - ...props + className, + ...props }: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to previous page" - size="default" - className={cn("gap-1 pl-2.5", className)} - {...props} - > - <ChevronLeft className="h-4 w-4" /> - <span>Previous</span> - </PaginationLink> + <PaginationLink + aria-label="Go to previous page" + size="default" + className={cn("gap-1 pl-2.5", className)} + {...props} + > + <ChevronLeft className="h-4 w-4" /> + <span>Previous</span> + </PaginationLink> ); PaginationPrevious.displayName = "PaginationPrevious"; const PaginationNext = ({ - className, - ...props + className, + ...props }: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to next page" - size="default" - className={cn("gap-1 pr-2.5", className)} - {...props} - > - <span>Next</span> - <ChevronRight className="h-4 w-4" /> - </PaginationLink> + <PaginationLink + aria-label="Go to next page" + size="default" + className={cn("gap-1 pr-2.5", className)} + {...props} + > + <span>Next</span> + <ChevronRight className="h-4 w-4" /> + </PaginationLink> ); PaginationNext.displayName = "PaginationNext"; const PaginationEllipsis = ({ - className, - ...props + className, + ...props }: React.ComponentProps<"span">) => ( - <span - aria-hidden - className={cn("flex h-9 w-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="h-4 w-4" /> - <span className="sr-only">More pages</span> - </span> + <span + aria-hidden + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More pages</span> + </span> ); PaginationEllipsis.displayName = "PaginationEllipsis"; export { - Pagination, - PaginationContent, - PaginationLink, - PaginationItem, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, }; diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx index 95ad9131..d682c813 100644 --- a/apps/web/src/components/ui/phone-input.tsx +++ b/apps/web/src/components/ui/phone-input.tsx @@ -1,167 +1,167 @@ -import * as React from "react";
-import { CheckIcon, ChevronsUpDown } from "lucide-react";
-import * as RPNInput from "react-phone-number-input";
-import flags from "react-phone-number-input/flags";
-
-import { Button } from "./button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "./command";
-import { Input } from "./input";
-import { Popover, PopoverContent, PopoverTrigger } from "./popover";
-import { ScrollArea } from "./scroll-area";
-import { cn } from "../../lib/utils";
-
-type PhoneInputProps = Omit<
- React.ComponentProps<"input">,
- "onChange" | "value" | "ref"
-> &
- Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
- onChange?: (value: RPNInput.Value) => void;
- };
-
-const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
- React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
- ({ className, onChange, defaultCountry = "US", ...props }, ref) => {
- return (
- <RPNInput.default
- ref={ref}
- className={cn("flex", className)}
- flagComponent={FlagComponent}
- countrySelectComponent={CountrySelect}
- inputComponent={InputComponent}
- defaultCountry={defaultCountry}
- international
- addInternationalOption
- smartCaret={false}
- /**
- * Handles the onChange event.
- *
- * react-phone-number-input might trigger the onChange event as undefined
- * when a valid phone number is not entered. To prevent this,
- * the value is coerced to an empty string.
- *
- * @param {E164Number | undefined} value - The entered value
- */
- onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
- {...props}
- />
- );
- }
- );
-PhoneInput.displayName = "PhoneInput";
-
-const InputComponent = React.forwardRef<
- HTMLInputElement,
- React.ComponentProps<"input">
->(({ className, ...props }, ref) => (
- <Input
- className={cn("rounded-e-lg rounded-s-none", className)}
- {...props}
- ref={ref}
- />
-));
-InputComponent.displayName = "InputComponent";
-
-type CountryEntry = { label: string; value: RPNInput.Country | undefined };
-
-type CountrySelectProps = {
- disabled?: boolean;
- value: RPNInput.Country;
- options: CountryEntry[];
- onChange: (country: RPNInput.Country) => void;
-};
-
-const CountrySelect = ({
- disabled,
- value: selectedCountry,
- options: countryList,
- onChange,
-}: CountrySelectProps) => {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- type="button"
- variant="outline"
- className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
- disabled={disabled}
- >
- <FlagComponent
- country={selectedCountry}
- countryName={selectedCountry}
- />
- <ChevronsUpDown
- className={cn(
- "-mr-2 size-4 opacity-50",
- disabled ? "hidden" : "opacity-100"
- )}
- />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[300px] p-0">
- <Command>
- <CommandInput placeholder="Search country..." />
- <CommandList>
- <ScrollArea className="h-72">
- <CommandEmpty>No country found.</CommandEmpty>
- <CommandGroup>
- {countryList.map(({ value, label }) =>
- value ? (
- <CountrySelectOption
- key={value}
- country={value}
- countryName={label}
- selectedCountry={selectedCountry}
- onChange={onChange}
- />
- ) : null
- )}
- </CommandGroup>
- </ScrollArea>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- );
-};
-
-interface CountrySelectOptionProps extends RPNInput.FlagProps {
- selectedCountry: RPNInput.Country;
- onChange: (country: RPNInput.Country) => void;
-}
-
-const CountrySelectOption = ({
- country,
- countryName,
- selectedCountry,
- onChange,
-}: CountrySelectOptionProps) => {
- return (
- <CommandItem className="gap-2" onSelect={() => onChange(country)}>
- <FlagComponent country={country} countryName={countryName} />
- <span className="flex-1 text-sm">{countryName}</span>
- <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
- <CheckIcon
- className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
- />
- </CommandItem>
- );
-};
-
-const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
- const Flag = flags[country];
-
- return (
- <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
- {Flag && <Flag title={countryName} />}
- </span>
- );
-};
-
-export { PhoneInput };
+import * as React from "react"; +import { CheckIcon, ChevronsUpDown } from "lucide-react"; +import * as RPNInput from "react-phone-number-input"; +import flags from "react-phone-number-input/flags"; + +import { Button } from "./button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "./command"; +import { Input } from "./input"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { ScrollArea } from "./scroll-area"; +import { cn } from "../../lib/utils"; + +type PhoneInputProps = Omit< + React.ComponentProps<"input">, + "onChange" | "value" | "ref" +> & + Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { + onChange?: (value: RPNInput.Value) => void; + }; + +const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = + React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( + ({ className, onChange, defaultCountry = "US", ...props }, ref) => { + return ( + <RPNInput.default + ref={ref} + className={cn("flex", className)} + flagComponent={FlagComponent} + countrySelectComponent={CountrySelect} + inputComponent={InputComponent} + defaultCountry={defaultCountry} + international + addInternationalOption + smartCaret={false} + /** + * Handles the onChange event. + * + * react-phone-number-input might trigger the onChange event as undefined + * when a valid phone number is not entered. To prevent this, + * the value is coerced to an empty string. + * + * @param {E164Number | undefined} value - The entered value + */ + onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} + {...props} + /> + ); + }, + ); +PhoneInput.displayName = "PhoneInput"; + +const InputComponent = React.forwardRef< + HTMLInputElement, + React.ComponentProps<"input"> +>(({ className, ...props }, ref) => ( + <Input + className={cn("rounded-e-lg rounded-s-none", className)} + {...props} + ref={ref} + /> +)); +InputComponent.displayName = "InputComponent"; + +type CountryEntry = { label: string; value: RPNInput.Country | undefined }; + +type CountrySelectProps = { + disabled?: boolean; + value: RPNInput.Country; + options: CountryEntry[]; + onChange: (country: RPNInput.Country) => void; +}; + +const CountrySelect = ({ + disabled, + value: selectedCountry, + options: countryList, + onChange, +}: CountrySelectProps) => { + return ( + <Popover> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10" + disabled={disabled} + > + <FlagComponent + country={selectedCountry} + countryName={selectedCountry} + /> + <ChevronsUpDown + className={cn( + "-mr-2 size-4 opacity-50", + disabled ? "hidden" : "opacity-100", + )} + /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <ScrollArea className="h-72"> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {countryList.map(({ value, label }) => + value ? ( + <CountrySelectOption + key={value} + country={value} + countryName={label} + selectedCountry={selectedCountry} + onChange={onChange} + /> + ) : null, + )} + </CommandGroup> + </ScrollArea> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}; + +interface CountrySelectOptionProps extends RPNInput.FlagProps { + selectedCountry: RPNInput.Country; + onChange: (country: RPNInput.Country) => void; +} + +const CountrySelectOption = ({ + country, + countryName, + selectedCountry, + onChange, +}: CountrySelectOptionProps) => { + return ( + <CommandItem className="gap-2" onSelect={() => onChange(country)}> + <FlagComponent country={country} countryName={countryName} /> + <span className="flex-1 text-sm">{countryName}</span> + <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span> + <CheckIcon + className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} + /> + </CommandItem> + ); +}; + +const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { + const Flag = flags[country]; + + return ( + <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full"> + {Flag && <Flag title={countryName} />} + </span> + ); +}; + +export { PhoneInput }; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index cc4f6508..cd304da5 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -12,21 +12,21 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< - React.ElementRef<typeof PopoverPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> + React.ElementRef<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - <PopoverPrimitive.Portal> - <PopoverPrimitive.Content - ref={ref} - align={align} - sideOffset={sideOffset} - className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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} - /> - </PopoverPrimitive.Portal> + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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} + /> + </PopoverPrimitive.Portal> )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index 283ba17a..d5bbd8e8 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -6,22 +6,22 @@ import { Progress as ProgressPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Progress = React.forwardRef< - React.ElementRef<typeof ProgressPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> + React.ElementRef<typeof ProgressPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> >(({ className, value, ...props }, ref) => ( - <ProgressPrimitive.Root - ref={ref} - className={cn( - "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", - className - )} - {...props} - > - <ProgressPrimitive.Indicator - className="h-full w-full flex-1 bg-primary transition-all" - style={{ transform: `translateX(-${100 - (value || 0)}%)` }} - /> - </ProgressPrimitive.Root> + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", + className, + )} + {...props} + > + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> )); Progress.displayName = ProgressPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx index cc07d34f..5aeb3586 100644 --- a/apps/web/src/components/ui/radio-group.tsx +++ b/apps/web/src/components/ui/radio-group.tsx @@ -7,37 +7,37 @@ import { Circle } from "lucide-react"; import { cn } from "../../lib/utils"; const RadioGroup = React.forwardRef< - React.ElementRef<typeof RadioGroupPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> >(({ className, ...props }, ref) => { - return ( - <RadioGroupPrimitive.Root - className={cn("grid gap-2", className)} - {...props} - ref={ref} - /> - ); + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ); }); RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; const RadioGroupItem = React.forwardRef< - React.ElementRef<typeof RadioGroupPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> >(({ className, ...props }, ref) => { - return ( - <RadioGroupPrimitive.Item - ref={ref} - className={cn( - "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - className - )} - {...props} - > - <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> - <Circle className="h-3.5 w-3.5 fill-primary" /> - </RadioGroupPrimitive.Indicator> - </RadioGroupPrimitive.Item> - ); + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-3.5 w-3.5 fill-primary" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); }); RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index a4443996..fa9fc3de 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -5,34 +5,34 @@ import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "../../lib/utils"; const ResizablePanelGroup = ({ - className, - ...props + className, + ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( - <ResizablePrimitive.PanelGroup - className={cn( - "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", - className - )} - {...props} - /> + <ResizablePrimitive.PanelGroup + className={cn( + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", + className, + )} + {...props} + /> ); const ResizablePanel = ResizablePrimitive.Panel; const ResizableHandle = ({ - withHandle, - className, - ...props + withHandle, + className, + ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { - withHandle?: boolean; + withHandle?: boolean; }) => ( - <ResizablePrimitive.PanelResizeHandle - className={cn( - "relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", - className - )} - {...props} - /> + <ResizablePrimitive.PanelResizeHandle + className={cn( + "relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + className, + )} + {...props} + /> ); export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 6b7c3c53..882d8745 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -6,42 +6,42 @@ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const ScrollArea = React.forwardRef< - React.ElementRef<typeof ScrollAreaPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> >(({ className, children, ...props }, ref) => ( - <ScrollAreaPrimitive.Root - ref={ref} - className={cn("relative overflow-hidden", className)} - {...props} - > - <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> - {children} - </ScrollAreaPrimitive.Viewport> - <ScrollBar /> - <ScrollAreaPrimitive.Corner /> - </ScrollAreaPrimitive.Root> + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> )); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< - React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> >(({ className, orientation = "vertical", ...props }, ref) => ( - <ScrollAreaPrimitive.ScrollAreaScrollbar - ref={ref} - orientation={orientation} - className={cn( - "flex touch-none select-none transition-colors", - orientation === "vertical" && - "h-full w-2.5 border-l border-l-transparent p-[1px]", - orientation === "horizontal" && - "h-2.5 flex-col border-t border-t-transparent p-[1px]", - className - )} - {...props} - > - <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> - </ScrollAreaPrimitive.ScrollAreaScrollbar> + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className, + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 911b3e5d..96dd59d0 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -14,165 +14,165 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const selectItemVariants = cva( - "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - { - variants: { - variant: { - default: "focus:opacity-65 focus:text-accent-foreground", - destructive: "text-destructive focus:text-destructive/80", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + }, ); const SelectTrigger = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <SelectPrimitive.Trigger - ref={ref} - className={cn( - "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", - className - )} - {...props} - > - {children} - <SelectPrimitive.Icon asChild> - <ChevronDown className="h-4 w-4 opacity-50" /> - </SelectPrimitive.Icon> - </SelectPrimitive.Trigger> + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectScrollUpButton = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> >(({ className, ...props }, ref) => ( - <SelectPrimitive.ScrollUpButton - ref={ref} - className={cn( - "flex cursor-default items-center justify-center py-1", - className - )} - {...props} - > - <ChevronUp className="h-4 w-4" /> - </SelectPrimitive.ScrollUpButton> + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> )); SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; const SelectScrollDownButton = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> >(({ className, ...props }, ref) => ( - <SelectPrimitive.ScrollDownButton - ref={ref} - className={cn( - "flex cursor-default items-center justify-center py-1", - className - )} - {...props} - > - <ChevronDown className="h-4 w-4" /> - </SelectPrimitive.ScrollDownButton> + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> )); SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; + SelectPrimitive.ScrollDownButton.displayName; const SelectContent = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> >(({ className, children, position = "popper", ...props }, ref) => ( - <SelectPrimitive.Portal> - <SelectPrimitive.Content - ref={ref} - className={cn( - "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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", - position === "popper" && - "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", - className - )} - position={position} - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - {...props} - > - <SelectScrollUpButton /> - <SelectPrimitive.Viewport - className={cn( - "p-1", - position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" - )} - > - {children} - </SelectPrimitive.Viewport> - <SelectScrollDownButton /> - </SelectPrimitive.Content> - </SelectPrimitive.Portal> + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> )); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> >(({ className, ...props }, ref) => ( - <SelectPrimitive.Label - ref={ref} - className={cn("px-2 py-1.5 text-sm font-semibold", className)} - {...props} - /> + <SelectPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", className)} + {...props} + /> )); SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { - variant?: VariantProps<typeof selectItemVariants>["variant"]; - } + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { + variant?: VariantProps<typeof selectItemVariants>["variant"]; + } >(({ className, children, variant = "default", ...props }, ref) => ( - <SelectPrimitive.Item - ref={ref} - className={cn(selectItemVariants({ variant }), className)} - {...props} - > - <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> - <SelectPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </SelectPrimitive.ItemIndicator> - </span> - <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> - </SelectPrimitive.Item> + <SelectPrimitive.Item + ref={ref} + className={cn(selectItemVariants({ variant }), className)} + {...props} + > + <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> )); SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> >(({ className, ...props }, ref) => ( - <SelectPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} - {...props} - /> + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} + {...props} + /> )); SelectSeparator.displayName = SelectPrimitive.Separator.displayName; export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx index 6138cabf..b82f9120 100644 --- a/apps/web/src/components/ui/separator.tsx +++ b/apps/web/src/components/ui/separator.tsx @@ -6,25 +6,25 @@ import { Separator as SeparatorPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Separator = React.forwardRef< - React.ElementRef<typeof SeparatorPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> >( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref - ) => ( - <SeparatorPrimitive.Root - ref={ref} - decorative={decorative} - orientation={orientation} - className={cn( - "shrink-0 bg-border", - orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", - className - )} - {...props} - /> - ) + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className, + )} + {...props} + /> + ), ); Separator.displayName = SeparatorPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 6aa092dc..033f02f9 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -16,125 +16,125 @@ const SheetClose = SheetPrimitive.Close; const SheetPortal = SheetPrimitive.Portal; const SheetOverlay = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> + React.ElementRef<typeof SheetPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> >(({ 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", - className - )} - {...props} - ref={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", + className, + )} + {...props} + ref={ref} + /> )); 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", - { - variants: { - side: { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - bottom: - "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", - right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", - }, - }, - defaultVariants: { - side: "right", - }, - } + "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", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + }, ); interface SheetContentProps - extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, - VariantProps<typeof sheetVariants> {} + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, + VariantProps<typeof sheetVariants> {} const SheetContent = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Content>, - SheetContentProps + React.ElementRef<typeof SheetPrimitive.Content>, + SheetContentProps >(({ side = "right", className, children, ...props }, ref) => ( - <SheetPortal> - <SheetOverlay /> - <SheetPrimitive.Content - ref={ref} - className={cn(sheetVariants({ side }), className)} - {...props} - > - <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none 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" /> - <span className="sr-only">Close</span> - </SheetPrimitive.Close> - {children} - </SheetPrimitive.Content> - </SheetPortal> + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + ref={ref} + className={cn(sheetVariants({ side }), className)} + {...props} + > + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none 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" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + {children} + </SheetPrimitive.Content> + </SheetPortal> )); SheetContent.displayName = SheetPrimitive.Content.displayName; const SheetHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className - )} - {...props} - /> + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className, + )} + {...props} + /> ); SheetHeader.displayName = "SheetHeader"; const SheetFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className - )} - {...props} - /> + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> ); SheetFooter.displayName = "SheetFooter"; const SheetTitle = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> + React.ElementRef<typeof SheetPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> >(({ className, ...props }, ref) => ( - <SheetPrimitive.Title - ref={ref} - className={cn("text-lg font-semibold text-foreground", className)} - {...props} - /> + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} + /> )); SheetTitle.displayName = SheetPrimitive.Title.displayName; const SheetDescription = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> + React.ElementRef<typeof SheetPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> >(({ className, ...props }, ref) => ( - <SheetPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); SheetDescription.displayName = SheetPrimitive.Description.displayName; export { - Sheet, - SheetPortal, - SheetOverlay, - SheetTrigger, - SheetClose, - SheetContent, - SheetHeader, - SheetFooter, - SheetTitle, - SheetDescription, + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, }; diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index c1583503..6ea6ab32 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -13,10 +13,10 @@ import { Separator } from "./separator"; import { Sheet, SheetContent, SheetTitle } from "./sheet"; import { Skeleton } from "./skeleton"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "./tooltip"; const SIDEBAR_COOKIE_NAME = "sidebar:state"; @@ -27,737 +27,745 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; type SidebarContext = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; }; const SidebarContext = React.createContext<SidebarContext | null>(null); function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } - return context; + return context; } const SidebarProvider = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } >( - ( - { - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props - }, - ref - ) => { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref, + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open] - ); + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile - ? setOpenMobile((open) => !open) - : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault(); - toggleSidebar(); - } - }; + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; - const contextValue = React.useMemo<SidebarContext>( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ); + const contextValue = React.useMemo<SidebarContext>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + ], + ); - return ( - <SidebarContext.Provider value={contextValue}> - <TooltipProvider delayDuration={0}> - <div - style={ - { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, - ...style, - } as React.CSSProperties - } - className={cn( - "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", - className - )} - ref={ref} - {...props} - > - {children} - </div> - </TooltipProvider> - </SidebarContext.Provider> - ); - } + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", + className, + )} + ref={ref} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ); + }, ); SidebarProvider.displayName = "SidebarProvider"; const Sidebar = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } >( - ( - { - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props - }, - ref - ) => { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref, + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - if (collapsible === "none") { - return ( - <div - className={cn( - "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", - className - )} - ref={ref} - {...props} - > - {children} - </div> - ); - } + if (collapsible === "none") { + return ( + <div + className={cn( + "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", + className, + )} + ref={ref} + {...props} + > + {children} + </div> + ); + } - if (isMobile) { - return ( - <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> - <SheetContent - data-sidebar="sidebar" - data-mobile="true" - className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } - side={side} - > - <SheetTitle className="sr-only">Navigation Menu</SheetTitle> - <div className="flex h-full w-full flex-col">{children}</div> - </SheetContent> - </Sheet> - ); - } + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-mobile="true" + className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <SheetTitle className="sr-only">Navigation Menu</SheetTitle> + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ); + } - return ( - <div - ref={ref} - className="group peer hidden md:block text-sidebar-foreground" - data-state={state} - data-collapsible={state === "collapsed" ? collapsible : ""} - data-variant={variant} - data-side={side} - > - {/* This is what handles the sidebar gap on desktop */} - <div - className={cn( - "duration-200 relative h-full w-[--sidebar-width] bg-transparent transition-[width] ease-linear", - "group-data-[collapsible=offcanvas]:w-0", - "group-data-[side=right]:rotate-180", - variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" - : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" - )} - /> - <div - className={cn( - "duration-200 fixed h-[calc(100vh-4rem)] top-16 z-10 hidden w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", - side === "left" - ? "left-0 border-r group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" - : "right-0 border-l group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", - variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" - : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", - className - )} - {...props} - > - <div - data-sidebar="sidebar" - className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" - > - {children} - </div> - </div> - </div> - ); - } + return ( + <div + ref={ref} + className="group peer hidden md:block text-sidebar-foreground" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + "duration-200 relative h-full w-[--sidebar-width] bg-transparent transition-[width] ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", + )} + /> + <div + className={cn( + "duration-200 fixed h-[calc(100vh-4rem)] top-16 z-10 hidden w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", + side === "left" + ? "left-0 border-r group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 border-l group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", + className, + )} + {...props} + > + <div + data-sidebar="sidebar" + className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" + > + {children} + </div> + </div> + </div> + ); + }, ); Sidebar.displayName = "Sidebar"; const SidebarTrigger = React.forwardRef< - React.ElementRef<typeof Button>, - React.ComponentProps<typeof Button> + React.ElementRef<typeof Button>, + React.ComponentProps<typeof Button> >(({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar } = useSidebar(); - return ( - <Button - ref={ref} - data-sidebar="trigger" - variant="text" - size="icon" - className={cn("h-8 w-8", className)} - onClick={(event) => { - onClick?.(event); - toggleSidebar(); - }} - {...props} - > - <PanelLeft /> - <span className="sr-only">Toggle Sidebar</span> - </Button> - ); + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="text" + size="icon" + className={cn("h-8 w-8", className)} + onClick={(event) => { + onClick?.(event); + toggleSidebar(); + }} + {...props} + > + <PanelLeft /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ); }); SidebarTrigger.displayName = "SidebarTrigger"; const SidebarRail = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> + HTMLButtonElement, + React.ComponentProps<"button"> >(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar } = useSidebar(); - return ( - <button - ref={ref} - data-sidebar="rail" - aria-label="Toggle Sidebar" - tabIndex={-1} - onClick={toggleSidebar} - title="Toggle Sidebar" - className={cn( - "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", - "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", - className - )} - {...props} - /> - ); + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", + "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className, + )} + {...props} + /> + ); }); SidebarRail.displayName = "SidebarRail"; const SidebarInset = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"main"> + HTMLDivElement, + React.ComponentProps<"main"> >(({ className, ...props }, ref) => { - return ( - <main - ref={ref} - className={cn( - "relative flex min-h-svh flex-1 flex-col bg-background", - "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", - className - )} - {...props} - /> - ); + return ( + <main + ref={ref} + className={cn( + "relative flex min-h-svh flex-1 flex-col bg-background", + "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + className, + )} + {...props} + /> + ); }); SidebarInset.displayName = "SidebarInset"; const SidebarInput = React.forwardRef< - React.ElementRef<typeof Input>, - React.ComponentProps<typeof Input> + React.ElementRef<typeof Input>, + React.ComponentProps<typeof Input> >(({ className, ...props }, ref) => { - return ( - <Input - ref={ref} - data-sidebar="input" - className={cn( - "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", - className - )} - {...props} - /> - ); + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", + className, + )} + {...props} + /> + ); }); SidebarInput.displayName = "SidebarInput"; const SidebarHeader = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="header" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); }); SidebarHeader.displayName = "SidebarHeader"; const SidebarFooter = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="footer" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); }); SidebarFooter.displayName = "SidebarFooter"; const SidebarSeparator = React.forwardRef< - React.ElementRef<typeof Separator>, - React.ComponentProps<typeof Separator> + React.ElementRef<typeof Separator>, + React.ComponentProps<typeof Separator> >(({ className, ...props }, ref) => { - return ( - <Separator - ref={ref} - data-sidebar="separator" - className={cn("mx-2 w-auto bg-sidebar-border", className)} - {...props} - /> - ); + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn("mx-2 w-auto bg-sidebar-border", className)} + {...props} + /> + ); }); SidebarSeparator.displayName = "SidebarSeparator"; const SidebarContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="content" - className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", - className - )} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className, + )} + {...props} + /> + ); }); SidebarContent.displayName = "SidebarContent"; const SidebarGroup = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="group" - className={cn("relative flex w-full min-w-0 flex-col p-2", className)} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ); }); SidebarGroup.displayName = "SidebarGroup"; const SidebarGroupLabel = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { asChild?: boolean } + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } >(({ className, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "div"; + const Comp = asChild ? SlotPrimitive.Slot : "div"; - return ( - <Comp - ref={ref as any} - data-sidebar="group-label" - className={cn( - "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="group-label" + className={cn( + "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className, + )} + {...props} + /> + ); }); SidebarGroupLabel.displayName = "SidebarGroupLabel"; const SidebarGroupAction = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { asChild?: boolean } + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } >(({ className, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; + const Comp = asChild ? SlotPrimitive.Slot : "button"; - return ( - <Comp - ref={ref as any} - data-sidebar="group-action" - className={cn( - "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="group-action" + className={cn( + "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> + ); }); SidebarGroupAction.displayName = "SidebarGroupAction"; const SidebarGroupContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => ( - <div - ref={ref} - data-sidebar="group-content" - className={cn("w-full text-sm", className)} - {...props} - /> + <div + ref={ref} + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> )); SidebarGroupContent.displayName = "SidebarGroupContent"; const SidebarMenu = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> + HTMLUListElement, + React.ComponentProps<"ul"> >(({ className, ...props }, ref) => ( - <ul - ref={ref} - data-sidebar="menu" - className={cn("flex w-full min-w-0 flex-col gap-1", className)} - {...props} - /> + <ul + ref={ref} + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} + /> )); SidebarMenu.displayName = "SidebarMenu"; const SidebarMenuItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> + HTMLLIElement, + React.ComponentProps<"li"> >(({ className, ...props }, ref) => ( - <li - ref={ref} - data-sidebar="menu-item" - className={cn("group/menu-item relative", className)} - {...props} - /> + <li + ref={ref} + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> )); SidebarMenuItem.displayName = "SidebarMenuItem"; const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, ); const SidebarMenuButton = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; - } & VariantProps<typeof sidebarMenuButtonVariants> + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + isActive?: boolean; + tooltip?: string | React.ComponentProps<typeof TooltipContent>; + } & VariantProps<typeof sidebarMenuButtonVariants> >( - ( - { - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props - }, - ref - ) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; - const { isMobile, state } = useSidebar(); + ( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref, + ) => { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + const { isMobile, state } = useSidebar(); - const button = ( - <Comp - ref={ref as any} - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); + const button = ( + <Comp + ref={ref as any} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ); - if (!tooltip) { - return button; - } + if (!tooltip) { + return button; + } - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + }; + } - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={state !== "collapsed" || isMobile} - {...tooltip} - /> - </Tooltip> - ); - } + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ); + }, ); SidebarMenuButton.displayName = "SidebarMenuButton"; const SidebarMenuAction = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; - } + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; + } >(({ className, asChild = false, showOnHover = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; + const Comp = asChild ? SlotPrimitive.Slot : "button"; - return ( - <Comp - ref={ref as any} - data-sidebar="menu-action" - className={cn( - "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", - className - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="menu-action" + className={cn( + "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className, + )} + {...props} + /> + ); }); SidebarMenuAction.displayName = "SidebarMenuAction"; const SidebarMenuBadge = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => ( - <div - ref={ref} - data-sidebar="menu-badge" - className={cn( - "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> )); SidebarMenuBadge.displayName = "SidebarMenuBadge"; const SidebarMenuSkeleton = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - showIcon?: boolean; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + showIcon?: boolean; + } >(({ className, showIcon = false, ...props }, ref) => { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); - return ( - <div - ref={ref} - data-sidebar="menu-skeleton" - className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} - {...props} - > - {showIcon && ( - <Skeleton - className="size-4 rounded-md" - data-sidebar="menu-skeleton-icon" - /> - )} - <Skeleton - className="h-4 flex-1 max-w-[--skeleton-width]" - data-sidebar="menu-skeleton-text" - style={ - { - "--skeleton-width": width, - } as React.CSSProperties - } - /> - </div> - ); + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 flex-1 max-w-[--skeleton-width]" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ); }); SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"; const SidebarMenuSub = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> + HTMLUListElement, + React.ComponentProps<"ul"> >(({ className, ...props }, ref) => ( - <ul - ref={ref} - data-sidebar="menu-sub" - className={cn( - "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> )); SidebarMenuSub.displayName = "SidebarMenuSub"; const SidebarMenuSubItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> + HTMLLIElement, + React.ComponentProps<"li"> >(({ ...props }, ref) => <li ref={ref} {...props} />); SidebarMenuSubItem.displayName = "SidebarMenuSubItem"; const SidebarMenuSubButton = React.forwardRef< - HTMLAnchorElement, - React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; - } + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean; + size?: "sm" | "md"; + isActive?: boolean; + } >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; + const Comp = asChild ? SlotPrimitive.Slot : "a"; - return ( - <Comp - ref={ref as any} - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} - className={cn( - "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className, + )} + {...props} + /> + ); }); SidebarMenuSubButton.displayName = "SidebarMenuSubButton"; export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, }; diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx index 4525c3a0..4ef76077 100644 --- a/apps/web/src/components/ui/skeleton.tsx +++ b/apps/web/src/components/ui/skeleton.tsx @@ -1,15 +1,15 @@ import { cn } from "../../lib/utils"; function Skeleton({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) { - return ( - <div - className={cn("animate-pulse rounded-md bg-primary/10", className)} - {...props} - /> - ); + return ( + <div + className={cn("animate-pulse rounded-md bg-primary/10", className)} + {...props} + /> + ); } export { Skeleton }; diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 4789ae17..7c42e862 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -6,22 +6,22 @@ import { Slider as SliderPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Slider = React.forwardRef< - React.ElementRef<typeof SliderPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> + React.ElementRef<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> >(({ className, ...props }, ref) => ( - <SliderPrimitive.Root - ref={ref} - className={cn( - "relative flex w-full touch-none select-none items-center", - className - )} - {...props} - > - <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> - <SliderPrimitive.Range className="absolute h-full bg-primary" /> - </SliderPrimitive.Track> - <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> - </SliderPrimitive.Root> + <SliderPrimitive.Root + ref={ref} + className={cn( + "relative flex w-full touch-none select-none items-center", + className, + )} + {...props} + > + <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 452f4d9f..c5ba1bfe 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,31 +1,31 @@ -"use client" +"use client"; -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; -type ToasterProps = React.ComponentProps<typeof Sonner> +type ToasterProps = React.ComponentProps<typeof Sonner>; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = "system" } = useTheme(); - return ( - <Sonner - theme={theme as ToasterProps["theme"]} - className="toaster group" - toastOptions={{ - classNames: { - toast: - "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", - description: "group-[.toast]:text-muted-foreground", - actionButton: - "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", - cancelButton: - "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", - }, - }} - {...props} - /> - ) -} + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; -export { Toaster } +export { Toaster }; diff --git a/apps/web/src/components/ui/sponsor-button.tsx b/apps/web/src/components/ui/sponsor-button.tsx index 79c708a2..ffbd8a4f 100644 --- a/apps/web/src/components/ui/sponsor-button.tsx +++ b/apps/web/src/components/ui/sponsor-button.tsx @@ -2,39 +2,38 @@ import { motion } from "motion/react"; import { ComponentType } from "react"; interface SponsorButtonProps { - href: string; - logo: ComponentType<{ className?: string }>; - companyName: string; - className?: string; + href: string; + logo: ComponentType<{ className?: string }>; + companyName: string; + className?: string; } export function SponsorButton({ - href, - logo: Logo, - companyName, - className = "", + href, + logo: Logo, + companyName, + className = "", }: SponsorButtonProps) { + return ( + <motion.a + href={href} + target="_blank" + rel="noopener noreferrer" + className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`} + > + <span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors"> + Sponsored by + </span> - return ( - <motion.a - href={href} - target="_blank" - rel="noopener noreferrer" - className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`} - > - <span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors"> - Sponsored by - </span> + <div className="flex items-center gap-1.5"> + <div className="text-zinc-100 group-hover:text-white transition-colors"> + <Logo className="w-4 h-4" /> + </div> - <div className="flex items-center gap-1.5"> - <div className="text-zinc-100 group-hover:text-white transition-colors"> - <Logo className="w-4 h-4" /> - </div> - - <span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors"> - {companyName} - </span> - </div> - </motion.a> - ); + <span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors"> + {companyName} + </span> + </div> + </motion.a> + ); } diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index ba324f1d..c77b564b 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -6,23 +6,23 @@ import { Switch as SwitchPrimitives } from "radix-ui"; import { cn } from "../../lib/utils"; const Switch = React.forwardRef< - React.ElementRef<typeof SwitchPrimitives.Root>, - React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> >(({ className, ...props }, ref) => ( - <SwitchPrimitives.Root - className={cn( - "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", - className - )} - {...props} - ref={ref} - > - <SwitchPrimitives.Thumb - className={cn( - "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" - )} - /> - </SwitchPrimitives.Root> + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className, + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0", + )} + /> + </SwitchPrimitives.Root> )); Switch.displayName = SwitchPrimitives.Root.displayName; diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx index 6b83618c..ed57d4e8 100644 --- a/apps/web/src/components/ui/table.tsx +++ b/apps/web/src/components/ui/table.tsx @@ -3,118 +3,118 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Table = React.forwardRef< - HTMLTableElement, - React.HTMLAttributes<HTMLTableElement> + HTMLTableElement, + React.HTMLAttributes<HTMLTableElement> >(({ className, ...props }, ref) => ( - <div className="relative w-full overflow-auto"> - <table - ref={ref} - className={cn("w-full caption-bottom text-sm", className)} - {...props} - /> - </div> + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + </div> )); Table.displayName = "Table"; const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes<HTMLTableSectionElement> + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> )); TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes<HTMLTableSectionElement> + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <tbody - ref={ref} - className={cn("[&_tr:last-child]:border-0", className)} - {...props} - /> + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> )); TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes<HTMLTableSectionElement> + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <tfoot - ref={ref} - className={cn( - "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", - className - )} - {...props} - /> + <tfoot + ref={ref} + className={cn( + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", + className, + )} + {...props} + /> )); TableFooter.displayName = "TableFooter"; const TableRow = React.forwardRef< - HTMLTableRowElement, - React.HTMLAttributes<HTMLTableRowElement> + HTMLTableRowElement, + React.HTMLAttributes<HTMLTableRowElement> >(({ className, ...props }, ref) => ( - <tr - ref={ref} - className={cn( - "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", - className - )} - {...props} - /> + <tr + ref={ref} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + className, + )} + {...props} + /> )); TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes<HTMLTableCellElement> + HTMLTableCellElement, + React.ThHTMLAttributes<HTMLTableCellElement> >(({ className, ...props }, ref) => ( - <th - ref={ref} - className={cn( - "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", - className - )} - {...props} - /> + <th + ref={ref} + className={cn( + "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> )); TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes<HTMLTableCellElement> + HTMLTableCellElement, + React.TdHTMLAttributes<HTMLTableCellElement> >(({ className, ...props }, ref) => ( - <td - ref={ref} - className={cn( - "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", - className - )} - {...props} - /> + <td + ref={ref} + className={cn( + "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> )); TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef< - HTMLTableCaptionElement, - React.HTMLAttributes<HTMLTableCaptionElement> + HTMLTableCaptionElement, + React.HTMLAttributes<HTMLTableCaptionElement> >(({ className, ...props }, ref) => ( - <caption - ref={ref} - className={cn("mt-4 text-sm text-muted-foreground", className)} - {...props} - /> + <caption + ref={ref} + className={cn("mt-4 text-sm text-muted-foreground", className)} + {...props} + /> )); TableCaption.displayName = "TableCaption"; export { - Table, - TableHeader, - TableBody, - TableFooter, - TableHead, - TableRow, - TableCell, - TableCaption, + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index a51a28a2..52cad853 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -8,47 +8,47 @@ import { cn } from "../../lib/utils"; const Tabs = TabsPrimitive.Root; const TabsList = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.List>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> >(({ className, ...props }, ref) => ( - <TabsPrimitive.List - ref={ref} - className={cn( - "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", - className - )} - {...props} - /> + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + className, + )} + {...props} + /> )); TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> >(({ className, ...props }, ref) => ( - <TabsPrimitive.Trigger - ref={ref} - className={cn( - "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", - className - )} - {...props} - /> + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", + className, + )} + {...props} + /> )); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> >(({ className, ...props }, ref) => ( - <TabsPrimitive.Content - ref={ref} - className={cn( - "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", - className - )} - {...props} - /> + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + className, + )} + {...props} + /> )); TabsContent.displayName = TabsPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index 64f81ce6..303afeb4 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -3,19 +3,19 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Textarea = React.forwardRef< - HTMLTextAreaElement, - React.ComponentProps<"textarea"> + HTMLTextAreaElement, + React.ComponentProps<"textarea"> >(({ className, ...props }, ref) => { - return ( - <textarea - className={cn( - "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", - className - )} - ref={ref} - {...props} - /> - ); + return ( + <textarea + className={cn( + "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", + className, + )} + ref={ref} + {...props} + /> + ); }); Textarea.displayName = "Textarea"; diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index bf89e350..102dde35 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -10,105 +10,105 @@ import { cn } from "../../lib/utils"; const ToastProvider = ToastPrimitives.Provider; const ToastViewport = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Viewport>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Viewport - ref={ref} - className={cn( - "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", - className - )} - {...props} - /> + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className, + )} + {...props} + /> )); ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", - { - variants: { - variant: { - default: "border bg-background text-foreground", - destructive: - "destructive group border-destructive/35 bg-destructive/15 text-destructive-foreground backdrop-blur-lg", - }, - }, - defaultVariants: { - variant: "default", - }, - } + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive/35 bg-destructive/15 text-destructive-foreground backdrop-blur-lg", + }, + }, + defaultVariants: { + variant: "default", + }, + }, ); const Toast = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Root>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & - VariantProps<typeof toastVariants> + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> >(({ className, variant, ...props }, ref) => { - return ( - <ToastPrimitives.Root - ref={ref} - className={cn(toastVariants({ variant }), className)} - {...props} - /> - ); + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ); }); Toast.displayName = ToastPrimitives.Root.displayName; const ToastAction = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Action>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Action - ref={ref} - className={cn( - "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive", - className - )} - {...props} - /> + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive", + className, + )} + {...props} + /> )); ToastAction.displayName = ToastPrimitives.Action.displayName; const ToastClose = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Close>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Close - ref={ref} - className={cn( - "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground group-[.destructive]:hover:text-red-50 group-[.destructive]:focus-visible:ring-red-400 group-[.destructive]:focus-visible:ring-offset-red-600", - className - )} - toast-close="" - {...props} - > - <X className="h-4 w-4" /> - </ToastPrimitives.Close> + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground group-[.destructive]:hover:text-red-50 group-[.destructive]:focus-visible:ring-red-400 group-[.destructive]:focus-visible:ring-offset-red-600", + className, + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> )); ToastClose.displayName = ToastPrimitives.Close.displayName; const ToastTitle = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Title>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Title - ref={ref} - className={cn("text-sm font-semibold [&+div]:text-xs", className)} - {...props} - /> + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold [&+div]:text-xs", className)} + {...props} + /> )); ToastTitle.displayName = ToastPrimitives.Title.displayName; const ToastDescription = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Description>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Description - ref={ref} - className={cn("text-sm opacity-90", className)} - {...props} - /> + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> )); ToastDescription.displayName = ToastPrimitives.Description.displayName; @@ -117,13 +117,13 @@ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; type ToastActionElement = React.ReactElement<typeof ToastAction>; export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction, + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, }; diff --git a/apps/web/src/components/ui/toaster.tsx b/apps/web/src/components/ui/toaster.tsx index 3eb8a720..69b18458 100644 --- a/apps/web/src/components/ui/toaster.tsx +++ b/apps/web/src/components/ui/toaster.tsx @@ -2,34 +2,34 @@ import { useToast } from "../../hooks/use-toast"; import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, } from "./toast"; export function Toaster() { - const { toasts } = useToast(); + const { toasts } = useToast(); - return ( - <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} - <ToastViewport /> - </ToastProvider> - ); + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ); + })} + <ToastViewport /> + </ToastProvider> + ); } diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index 5842a07d..e7297524 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -8,52 +8,52 @@ import { cn } from "../../lib/utils"; import { toggleVariants } from "./toggle"; const ToggleGroupContext = React.createContext< - VariantProps<typeof toggleVariants> + VariantProps<typeof toggleVariants> >({ - size: "default", - variant: "default", + size: "default", + variant: "default", }); const ToggleGroup = React.forwardRef< - React.ElementRef<typeof ToggleGroupPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & - VariantProps<typeof toggleVariants> + React.ElementRef<typeof ToggleGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & + VariantProps<typeof toggleVariants> >(({ className, variant, size, children, ...props }, ref) => ( - <ToggleGroupPrimitive.Root - ref={ref} - className={cn("flex items-center justify-center gap-1", className)} - {...props} - > - <ToggleGroupContext.Provider value={{ variant, size }}> - {children} - </ToggleGroupContext.Provider> - </ToggleGroupPrimitive.Root> + <ToggleGroupPrimitive.Root + ref={ref} + className={cn("flex items-center justify-center gap-1", className)} + {...props} + > + <ToggleGroupContext.Provider value={{ variant, size }}> + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive.Root> )); ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; const ToggleGroupItem = React.forwardRef< - React.ElementRef<typeof ToggleGroupPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & - VariantProps<typeof toggleVariants> + React.ElementRef<typeof ToggleGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & + VariantProps<typeof toggleVariants> >(({ className, children, variant, size, ...props }, ref) => { - const context = React.useContext(ToggleGroupContext); + const context = React.useContext(ToggleGroupContext); - return ( - <ToggleGroupPrimitive.Item - ref={ref} - className={cn( - toggleVariants({ - variant: context.variant || variant, - size: context.size || size, - }), - className - )} - {...props} - > - {children} - </ToggleGroupPrimitive.Item> - ); + return ( + <ToggleGroupPrimitive.Item + ref={ref} + className={cn( + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), + className, + )} + {...props} + > + {children} + </ToggleGroupPrimitive.Item> + ); }); ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index a4bfda42..4275bee9 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -7,37 +7,37 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", - }, - size: { - default: "h-9 px-2 min-w-9", - sm: "h-8 px-1.5 min-w-8", - lg: "h-10 px-2.5 min-w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, ); const Toggle = React.forwardRef< - React.ElementRef<typeof TogglePrimitive.Root>, - React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & - VariantProps<typeof toggleVariants> + React.ElementRef<typeof TogglePrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & + VariantProps<typeof toggleVariants> >(({ className, variant, size, ...props }, ref) => ( - <TogglePrimitive.Root - ref={ref} - className={cn(toggleVariants({ variant, size, className }))} - {...props} - /> + <TogglePrimitive.Root + ref={ref} + className={cn(toggleVariants({ variant, size, className }))} + {...props} + /> )); Toggle.displayName = TogglePrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 4971e262..4163c0dd 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -12,20 +12,20 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipContent = React.forwardRef< - React.ElementRef<typeof TooltipPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( - <TooltipPrimitive.Portal> - <TooltipPrimitive.Content - ref={ref} - sideOffset={sideOffset} - className={cn( - "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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} - /> - </TooltipPrimitive.Portal> + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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} + /> + </TooltipPrimitive.Portal> )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index 29d82cdf..1a44ef76 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -4,128 +4,128 @@ import { useRef, useEffect } from "react"; import { usePlaybackStore } from "@/stores/playback-store"; interface VideoPlayerProps { - src: string; - poster?: string; - className?: string; - clipStartTime: number; - trimStart: number; - trimEnd: number; - clipDuration: number; + src: string; + poster?: string; + className?: string; + clipStartTime: number; + trimStart: number; + trimEnd: number; + clipDuration: number; } export function VideoPlayer({ - src, - poster, - className = "", - clipStartTime, - trimStart, - trimEnd, - clipDuration, + src, + poster, + className = "", + clipStartTime, + trimStart, + trimEnd, + clipDuration, }: VideoPlayerProps) { - const videoRef = useRef<HTMLVideoElement>(null); - const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); + const videoRef = useRef<HTMLVideoElement>(null); + const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); - // Calculate if we're within this clip's timeline range - const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); - const isInClipRange = - currentTime >= clipStartTime && currentTime < clipEndTime; + // Calculate if we're within this clip's timeline range + const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); + const isInClipRange = + currentTime >= clipStartTime && currentTime < clipEndTime; - // Sync playback events - useEffect(() => { - const video = videoRef.current; - if (!video || !isInClipRange) return; + // Sync playback events + useEffect(() => { + const video = videoRef.current; + if (!video || !isInClipRange) return; - const handleSeekEvent = (e: CustomEvent) => { - // Always update video time, even if outside clip range - const timelineTime = e.detail.time; - const videoTime = Math.max( - trimStart, - Math.min( - clipDuration - trimEnd, - timelineTime - clipStartTime + trimStart - ) - ); - video.currentTime = videoTime; - }; + const handleSeekEvent = (e: CustomEvent) => { + // Always update video time, even if outside clip range + const timelineTime = e.detail.time; + const videoTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart, + ), + ); + video.currentTime = videoTime; + }; - const handleUpdateEvent = (e: CustomEvent) => { - // Always update video time, even if outside clip range - const timelineTime = e.detail.time; - const targetTime = Math.max( - trimStart, - Math.min( - clipDuration - trimEnd, - timelineTime - clipStartTime + trimStart - ) - ); + const handleUpdateEvent = (e: CustomEvent) => { + // Always update video time, even if outside clip range + const timelineTime = e.detail.time; + const targetTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart, + ), + ); - if (Math.abs(video.currentTime - targetTime) > 0.5) { - video.currentTime = targetTime; - } - }; + if (Math.abs(video.currentTime - targetTime) > 0.5) { + video.currentTime = targetTime; + } + }; - const handleSpeed = (e: CustomEvent) => { - video.playbackRate = e.detail.speed; - }; + const handleSpeed = (e: CustomEvent) => { + video.playbackRate = e.detail.speed; + }; - window.addEventListener("playback-seek", handleSeekEvent as EventListener); - window.addEventListener( - "playback-update", - handleUpdateEvent as EventListener - ); - window.addEventListener("playback-speed", handleSpeed as EventListener); + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + window.addEventListener( + "playback-update", + handleUpdateEvent as EventListener, + ); + window.addEventListener("playback-speed", handleSpeed as EventListener); - return () => { - window.removeEventListener( - "playback-seek", - handleSeekEvent as EventListener - ); - window.removeEventListener( - "playback-update", - handleUpdateEvent as EventListener - ); - window.removeEventListener( - "playback-speed", - handleSpeed as EventListener - ); - }; - }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); + return () => { + window.removeEventListener( + "playback-seek", + handleSeekEvent as EventListener, + ); + window.removeEventListener( + "playback-update", + handleUpdateEvent as EventListener, + ); + window.removeEventListener( + "playback-speed", + handleSpeed as EventListener, + ); + }; + }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); - // Sync playback state - useEffect(() => { - const video = videoRef.current; - if (!video) return; + // Sync playback state + useEffect(() => { + const video = videoRef.current; + if (!video) return; - if (isPlaying && isInClipRange) { - video.play().catch(() => {}); - } else { - video.pause(); - } - }, [isPlaying, isInClipRange]); + if (isPlaying && isInClipRange) { + video.play().catch(() => {}); + } else { + video.pause(); + } + }, [isPlaying, isInClipRange]); - // Sync volume and speed - useEffect(() => { - const video = videoRef.current; - if (!video) return; + // Sync volume and speed + useEffect(() => { + const video = videoRef.current; + if (!video) return; - video.volume = volume; - video.muted = muted; - video.playbackRate = speed; - }, [volume, speed, muted]); + video.volume = volume; + video.muted = muted; + video.playbackRate = speed; + }, [volume, speed, muted]); - return ( - <video - ref={videoRef} - src={src} - poster={poster} - className={`max-w-full max-h-full object-contain ${className}`} - playsInline - preload="auto" - controls={false} - disablePictureInPicture - disableRemotePlayback - style={{ pointerEvents: "none" }} - onContextMenu={(e) => e.preventDefault()} - /> - ); + return ( + <video + ref={videoRef} + src={src} + poster={poster} + className={`max-w-full max-h-full object-contain ${className}`} + playsInline + preload="auto" + controls={false} + disablePictureInPicture + disableRemotePlayback + style={{ pointerEvents: "none" }} + onContextMenu={(e) => e.preventDefault()} + /> + ); } |
