diff options
| author | ryu <ryumacodes@gmail.com> | 2025-08-07 19:37:06 +0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-07 19:37:06 +0500 |
| commit | e71788014d137a74f10420601451ddfbb84701bb (patch) | |
| tree | 2c08f7fbbc7537b12935e842981830aed976ce87 /apps/web/src/components | |
| parent | 6412158003f295a350fc66800796f30c8f3c534b (diff) | |
| parent | d022a78ae2330665e7d763fdc6e831a71f3b9ea1 (diff) | |
| download | opencut-e71788014d137a74f10420601451ddfbb84701bb.tar.gz | |
Merge branch 'main' into feat/timeline-return-to-start
Diffstat (limited to 'apps/web/src/components')
58 files changed, 2019 insertions, 823 deletions
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index a640dc4c..0457cb05 100644 --- a/apps/web/src/components/background-settings.tsx +++ b/apps/web/src/components/background-settings.tsx @@ -3,7 +3,7 @@ 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 { colors } from "@/data/colors/solid"; import { useProjectStore } from "@/stores/project-store"; import { PipetteIcon } from "lucide-react"; @@ -40,12 +40,12 @@ export function BackgroundSettings() { <Button variant="text" size="icon" - className="!size-4 border border-muted-foreground" + className="size-4! border border-muted-foreground" > - <BackgroundIcon className="!size-3" /> + <BackgroundIcon className="size-3!" /> </Button> </PopoverTrigger> - <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0"> + <PopoverContent className="flex flex-col items-start w-[20rem] h-64 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"> @@ -100,7 +100,7 @@ function ColorView({ }) { 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 className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-linear-to-b from-popover to-transparent pointer-events-none" /> <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" /> diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx index e2b32dda..e8f8703c 100644 --- a/apps/web/src/components/delete-project-dialog.tsx +++ b/apps/web/src/components/delete-project-dialog.tsx @@ -49,7 +49,7 @@ export function DeleteProjectDialog({ </DialogHeader> <DialogFooter> <Button - variant="outline" + variant="text" onClick={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 837bd97a..77202c36 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,23 +1,43 @@ "use client"; -import Link from "next/link"; import { Button } from "./ui/button"; -import { ChevronLeft, Download } from "lucide-react"; +import { + ChevronDown, + ArrowLeft, + Download, + SquarePen, + Trash, + Sun, +} from "lucide-react"; import { useTimelineStore } from "@/stores/timeline-store"; import { HeaderBase } from "./header-base"; import { formatTimeCode } from "@/lib/time"; import { useProjectStore } from "@/stores/project-store"; import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; -import { useState, useRef } from "react"; -import { Input } from "@/components/ui/input"; -import { toast } from "sonner"; +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import Link from "next/link"; +import { RenameProjectDialog } from "./rename-project-dialog"; +import { DeleteProjectDialog } from "./delete-project-dialog"; +import { useRouter } from "next/navigation"; +import { FaDiscord } from "react-icons/fa6"; +import { useTheme } from "next-themes"; +import { usePlaybackStore } from "@/stores/playback-store"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); - const { activeProject, renameProject } = useProjectStore(); - const [isEditing, setIsEditing] = useState(false); - const [newName, setNewName] = useState(activeProject?.name || ""); - const inputRef = useRef<HTMLInputElement>(null); + const { currentTime } = usePlaybackStore(); + const { activeProject, renameProject, deleteProject } = useProjectStore(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const router = useRouter(); + const { theme, setTheme } = useTheme(); const handleExport = () => { // TODO: Implement export functionality @@ -26,72 +46,96 @@ export function EditorHeader() { window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank"); }; - const handleNameClick = () => { - if (!activeProject) return; - setNewName(activeProject.name); - setIsEditing(true); - }; - - const handleNameSave = async () => { + const handleNameSave = async (newName: string) => { + console.log("handleNameSave", newName); if (activeProject && newName.trim() && newName !== activeProject.name) { try { await renameProject(activeProject.id, newName.trim()); + setIsRenameDialogOpen(false); } catch (error) { console.error("Failed to rename project:", error); - setNewName(activeProject.name); } } - setIsEditing(false); }; - const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { - if (e.key === "Enter") handleNameSave(); - else if (e.key === "Escape") setIsEditing(false); + const handleDelete = () => { + if (activeProject) { + deleteProject(activeProject.id); + setIsDeleteDialogOpen(false); + router.push("/projects"); + } }; 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" /> - </Link> - <div className="w-40 xl:w-60 h-9 flex items-center"> - {isEditing ? ( - <Input - ref={inputRef} - className="text-sm font-medium w-full px-2 py-1 h-9 truncate" - value={newName} - onChange={(e) => setNewName(e.target.value)} - onBlur={handleNameSave} - onKeyDown={handleInputKeyDown} - onFocus={(e) => e.target.select()} - maxLength={64} - aria-label="Project name" - autoFocus - /> - ) : ( - <span - className="text-sm font-medium cursor-pointer hover:underline" - title="Click to rename" - role="button" - tabIndex={0} - onClick={handleNameClick} - onKeyDown={(e) => e.key === "Enter" && handleNameClick()} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="secondary" + className="h-auto py-1.5 px-2.5 flex items-center justify-center" > - <div className="truncate text-ellipsis overflow-clip max-w-40 xl:max-w-60"> - {activeProject?.name} - </div> - </span> - )} - </div> + <ChevronDown className="text-muted-foreground" /> + <span className="text-[0.85rem] mr-2">{activeProject?.name}</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-40"> + <Link href="/projects"> + <DropdownMenuItem className="flex items-center gap-1.5"> + <ArrowLeft className="h-4 w-4" /> + Projects + </DropdownMenuItem> + </Link> + <DropdownMenuItem + className="flex items-center gap-1.5" + onClick={() => setIsRenameDialogOpen(true)} + > + <SquarePen className="h-4 w-4" /> + Rename project + </DropdownMenuItem> + <DropdownMenuItem + variant="destructive" + className="flex items-center gap-1.5" + onClick={() => setIsDeleteDialogOpen(true)} + > + <Trash className="h-4 w-4" /> + Delete Project + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <Link + href="https://discord.gg/zmR9N35cjK" + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5" + > + <FaDiscord className="h-4 w-4" /> + Discord + </Link> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <RenameProjectDialog + isOpen={isRenameDialogOpen} + onOpenChange={setIsRenameDialogOpen} + onConfirm={handleNameSave} + projectName={activeProject?.name || ""} + /> + <DeleteProjectDialog + isOpen={isDeleteDialogOpen} + onOpenChange={setIsDeleteDialogOpen} + onConfirm={handleDelete} + projectName={activeProject?.name || ""} + /> </div> ); const centerContent = ( <div className="flex items-center gap-2 text-xs"> - <span> + <span className="text-foreground tabular-nums"> + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + </span> + <span className="text-foreground/50">/</span> + <span className="text-foreground/50 tabular-nums"> {formatTimeCode( getTotalDuration(), "HH:MM:SS:FF", @@ -106,12 +150,20 @@ export function EditorHeader() { <KeyboardShortcutsHelp /> <Button size="sm" - variant="primary" - className="h-7 text-xs" + className="h-8 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity" onClick={handleExport} > <Download className="h-4 w-4" /> - <span className="text-sm">Export</span> + <span className="text-sm pr-1">Export</span> + </Button> + <Button + size="icon" + variant="text" + className="h-7" + onClick={() => setTheme(theme === "dark" ? "light" : "dark")} + > + <Sun className="!size-[1.1rem]" /> + <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span> </Button> </nav> ); @@ -121,7 +173,7 @@ export function EditorHeader() { leftContent={leftContent} centerContent={centerContent} rightContent={rightContent} - className="bg-background h-[3.2rem] px-4 items-center" + className="bg-background h-[3.2rem] px-3 items-center mt-0.5" /> ); } diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f995..372a31c5 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ useEffect(() => { let mounted = true; - + let ws = wavesurfer.current; + const initWaveSurfer = async () => { if (!waveformRef.current || !audioUrl) return; try { - // Clean up any existing instance - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } + // Clear any existing instance safely + if (ws) { + // Instead of immediately destroying, just set to null + // We'll destroy it outside this function wavesurfer.current = null; } - wavesurfer.current = WaveSurfer.create({ + // Create a fresh instance + const newWaveSurfer = WaveSurfer.create({ container: waveformRef.current, waveColor: "rgba(255, 255, 255, 0.6)", progressColor: "rgba(255, 255, 255, 0.9)", @@ -46,15 +45,28 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ interact: false, }); + // Assign to ref only if component is still mounted + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + // Component unmounted during initialization, clean up + try { + newWaveSurfer.destroy(); + } catch (e) { + // Ignore destroy errors + } + return; + } + // Event listeners - wavesurfer.current.on("ready", () => { + newWaveSurfer.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on("error", (err) => { + newWaveSurfer.on("error", (err) => { console.error("WaveSurfer error:", err); if (mounted) { setError(true); @@ -62,7 +74,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ } }); - await wavesurfer.current.load(audioUrl); + await newWaveSurfer.load(audioUrl); } catch (err) { console.error("Failed to initialize WaveSurfer:", err); if (mounted) { @@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ } }; - initWaveSurfer(); - - return () => { - mounted = false; - if (wavesurfer.current) { + // First safely destroy previous instance if it exists + if (ws) { + // Use this pattern to safely destroy the previous instance + const wsToDestroy = ws; + // Detach from ref immediately + wavesurfer.current = null; + + // Wait a tick to destroy so any pending operations can complete + requestAnimationFrame(() => { try { - wavesurfer.current.destroy(); + wsToDestroy.destroy(); } catch (e) { - // Silently ignore destroy errors + // Ignore errors during destroy + } + // Only initialize new instance after destroying the old one + if (mounted) { + initWaveSurfer(); } - wavesurfer.current = null; + }); + } else { + // No previous instance to clean up, initialize directly + initWaveSurfer(); + } + + return () => { + // Mark component as unmounted + mounted = false; + + // Store reference to current wavesurfer instance + const wsToDestroy = wavesurfer.current; + + // Immediately clear the ref to prevent accessing it after unmount + wavesurfer.current = null; + + // If we have an instance to clean up, do it safely + if (wsToDestroy) { + // Delay destruction to avoid race conditions + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch (e) { + // Ignore destroy errors - they're expected + } + }); } }; }, [audioUrl, height]); diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 359612f8..70eb69dd 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -4,14 +4,16 @@ import { TabBar } from "./tabbar"; import { MediaView } from "./views/media"; import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; -import { AudioView } from "./views/audio"; +import { SoundsView } from "./views/sounds"; +import { Separator } from "@/components/ui/separator"; +import { SettingsView } from "./views/settings"; export function MediaPanel() { const { activeTab } = useMediaPanelStore(); const viewMap: Record<Tab, React.ReactNode> = { media: <MediaView />, - audio: <AudioView />, + sounds: <SoundsView />, text: <TextView />, stickers: ( <div className="p-4 text-muted-foreground"> @@ -43,12 +45,14 @@ export function MediaPanel() { Adjustment view coming soon... </div> ), + settings: <SettingsView />, }; return ( - <div className="h-full flex flex-col bg-panel rounded-sm"> + <div className="h-full flex bg-panel"> <TabBar /> - <div className="flex-1 overflow-y-auto">{viewMap[activeTab]}</div> + <Separator orientation="vertical" /> + <div className="flex-1 overflow-hidden">{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 c661f51e..e1212030 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -9,28 +9,30 @@ import { SlidersHorizontalIcon, LucideIcon, TypeIcon, + SettingsIcon, } from "lucide-react"; import { create } from "zustand"; export type Tab = | "media" - | "audio" + | "sounds" | "text" | "stickers" | "effects" | "transitions" | "captions" | "filters" - | "adjustment"; + | "adjustment" + | "settings"; export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { media: { icon: VideoIcon, label: "Media", }, - audio: { + sounds: { icon: MusicIcon, - label: "Audio", + label: "Sounds", }, text: { icon: TypeIcon, @@ -60,6 +62,10 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { icon: SlidersHorizontalIcon, label: "Adjustment", }, + settings: { + icon: SettingsIcon, + label: "Settings", + }, }; interface MediaPanelStore { diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index bce84a46..fc4f677d 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -69,21 +69,20 @@ export function TabBar() { /> <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" + className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4" > {(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" + "flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75", + activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground" )} onClick={() => setActiveTab(tabKey)} key={tabKey} > - <tab.icon className="!size-[1.1rem]" /> - <span className="text-[0.65rem]">{tab.label}</span> + <tab.icon className="size-[1.1rem]!" /> </div> ); })} @@ -114,10 +113,10 @@ function ScrollButton({ <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" + className="rounded-[0.4rem] w-4 h-7 bg-foreground/10!" onClick={onClick} > - <Icon className="!size-4 text-foreground" /> + <Icon className="size-4! text-foreground" /> </Button> </div> ); diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx deleted file mode 100644 index ba64d749..00000000 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState } from "react"; - -export function AudioView() { - const [search, setSearch] = useState(""); - return ( - <div className="h-full flex flex-col gap-2 p-4"> - <Input - placeholder="Search songs and artists" - value={search} - onChange={(e) => setSearch(e.target.value)} - /> - <div className="flex flex-col gap-2" /> - </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 f218dc14..772ef4ab 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,8 +3,18 @@ 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 { + ArrowDown01, + CloudUpload, + Grid2X2, + Image, + List, + Loader2, + Music, + Search, + Video, +} from "lucide-react"; +import { useEffect, useRef, useState, useMemo } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; @@ -14,27 +24,62 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { Input } from "@/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { usePanelStore } from "@/stores/panel-store"; + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaItem; + children: React.ReactNode; + onRemove: (e: React.MouseEvent, id: string) => Promise<void>; +}) { + return ( + <ContextMenu> + <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => onRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); + const { mediaViewMode, setMediaViewMode } = usePanelStore(); 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 [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( + "name" + ); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const processFiles = async (files: FileList | File[]) => { if (!files || files.length === 0) return; @@ -100,7 +145,7 @@ export function MediaView() { const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); useEffect(() => { - const filtered = mediaItems.filter((item) => { + let filtered = mediaItems.filter((item) => { if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { return false; } @@ -115,79 +160,117 @@ export function MediaView() { return true; }); + filtered.sort((a, b) => { + let valueA: any; + let valueB: any; + + switch (sortBy) { + case "name": + valueA = a.name.toLowerCase(); + valueB = b.name.toLowerCase(); + break; + case "type": + valueA = a.type; + valueB = b.type; + break; + case "duration": + valueA = a.duration || 0; + valueB = b.duration || 0; + break; + case "size": + valueA = a.file.size; + valueB = b.file.size; + break; + default: + return 0; + } + + if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); + }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); - 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> - ); - } + const previewComponents = useMemo(() => { + const previews = new Map<string, React.ReactNode>(); - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( - <div className="relative w-full h-full"> + filteredMediaItems.forEach((item) => { + let preview: React.ReactNode; + + if (item.type === "image") { + preview = ( + <div className="w-full h-full flex items-center justify-center"> <img - src={item.thumbnailUrl} + src={item.url} alt={item.name} - className="w-full h-full object-cover rounded" + className="max-w-full max-h-full object-contain" 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> + ); + } else if (item.type === "video") { + if (item.thumbnailUrl) { + preview = ( + <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> + ); + } else { + preview = ( + <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> + ); + } + } else if (item.type === "audio") { + preview = ( + <div className="w-full h-full bg-linear-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 && ( - <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + <span className="text-xs opacity-70"> {formatDuration(item.duration)} - </div> + </span> )} </div> ); + } else { + preview = ( + <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 ( - <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> - ); - } + previews.set(item.id, preview); + }); - 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 previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaItem) => previewComponents.get(item.id); return ( <> @@ -207,43 +290,152 @@ export function MediaView() { > <div className="p-3 pb-2 bg-panel"> {/* Search and filter controls */} - <div className="flex gap-2"> - <Select value={mediaFilter} onValueChange={setMediaFilter}> - <SelectTrigger className="w-[80px] h-9 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <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-9 text-xs" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> + <div className="flex items-center gap-2"> <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 h-9" + className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity" > {isProcessing ? ( <Loader2 className="h-4 w-4 animate-spin" /> ) : ( - <Plus className="h-4 w-4" /> + <CloudUpload className="h-4 w-4" /> )} + <span>Upload</span> </Button> + <div className="flex items-center gap-0"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + size="icon" + variant="text" + onClick={() => + setMediaViewMode( + mediaViewMode === "grid" ? "list" : "grid" + ) + } + disabled={isProcessing} + className="justify-center items-center" + > + {mediaViewMode === "grid" ? ( + <List strokeWidth={1.5} className="!size-[1.05rem]" /> + ) : ( + <Grid2X2 + strokeWidth={1.5} + className="!size-[1.05rem]" + /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} + </p> + </TooltipContent> + <Tooltip> + <DropdownMenu> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button + size="icon" + variant="text" + disabled={isProcessing} + className="justify-center items-center" + > + <ArrowDown01 + strokeWidth={1.5} + className="!size-[1.05rem]" + /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => { + if (sortBy === "name") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("name"); + setSortOrder("asc"); + } + }} + > + Name{" "} + {sortBy === "name" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + if (sortBy === "type") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("type"); + setSortOrder("asc"); + } + }} + > + Type{" "} + {sortBy === "type" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + if (sortBy === "duration") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("duration"); + setSortOrder("asc"); + } + }} + > + Duration{" "} + {sortBy === "duration" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + if (sortBy === "size") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("size"); + setSortOrder("asc"); + } + }} + > + File Size{" "} + {sortBy === "size" && + (sortOrder === "asc" ? "↑" : "↓")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </Tooltip> + </TooltipProvider> + </div> </div> </div> - <ScrollArea className="h-full"> - <div className="flex-1 p-3 pt-0"> + <div className="h-full w-full overflow-y-auto scrollbar-thin"> + <div className="flex-1 p-3 pt-0 w-full"> {isDragOver || filteredMediaItems.length === 0 ? ( <MediaDragOverlay isVisible={true} @@ -252,50 +444,105 @@ export function MediaView() { onClick={handleFileSelect} isEmptyState={filteredMediaItems.length === 0 && !isDragOver} /> + ) : mediaViewMode === "grid" ? ( + <GridView + filteredMediaItems={filteredMediaItems} + renderPreview={renderPreview} + handleRemove={handleRemove} + /> ) : ( - <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> + <ListView + filteredMediaItems={filteredMediaItems} + renderPreview={renderPreview} + handleRemove={handleRemove} + formatDuration={formatDuration} + /> )} </div> - </ScrollArea> + </div> </div> </> ); } + +function GridView({ + filteredMediaItems, + renderPreview, + handleRemove, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; +}) { + return ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {filteredMediaItems.map((item) => ( + <MediaItemWithContextMenu + key={item.id} + item={item} + onRemove={handleRemove} + > + <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} + variant="card" + /> + </MediaItemWithContextMenu> + ))} + </div> + ); +} + +function ListView({ + filteredMediaItems, + renderPreview, + handleRemove, + formatDuration, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; + formatDuration: (duration: number) => string; +}) { + return ( + <div className="space-y-1"> + {filteredMediaItems.map((item) => ( + <MediaItemWithContextMenu + key={item.id} + item={item} + onRemove={handleRemove} + > + <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) + } + variant="compact" + /> + </MediaItemWithContextMenu> + ))} + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx new file mode 100644 index 00000000..3bce1753 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, + PropertyGroup, +} from "../../properties-panel/property-item"; +import { FPS_PRESETS } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useEditorStore } from "@/stores/editor-store"; +import { useAspectRatio } from "@/hooks/use-aspect-ratio"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; +import { colors } from "@/data/colors/solid"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { PipetteIcon } from "lucide-react"; +import { useMemo, memo, useCallback } from "react"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; + +export function SettingsView() { + return <ProjectSettingsTabs />; +} + +function ProjectSettingsTabs() { + return ( + <div className="h-full flex flex-col"> + <Tabs defaultValue="project-info" className="flex flex-col h-full"> + <div className="px-3 pt-4 pb-0"> + <TabsList> + <TabsTrigger value="project-info">Project info</TabsTrigger> + <TabsTrigger value="background">Background</TabsTrigger> + </TabsList> + </div> + <Separator className="my-4" /> + <ScrollArea className="flex-1"> + <TabsContent value="project-info" className="p-5 pt-0 mt-0"> + <ProjectInfoView /> + </TabsContent> + <TabsContent value="background" className="p-4 pt-0"> + <BackgroundView /> + </TabsContent> + </ScrollArea> + </Tabs> + </div> + ); +} + +function ProjectInfoView() { + const { activeProject, updateProjectFps } = useProjectStore(); + const { canvasPresets, setCanvasSize } = useEditorStore(); + const { getDisplayName } = useAspectRatio(); + + const handleAspectRatioChange = (value: string) => { + const preset = canvasPresets.find((p) => p.name === value); + if (preset) { + setCanvasSize({ width: preset.width, height: preset.height }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + updateProjectFps(fps); + }; + + return ( + <div className="flex flex-col gap-4"> + <PropertyItem direction="column"> + <PropertyItemLabel>Name</PropertyItemLabel> + <PropertyItemValue> + {activeProject?.name || "Untitled project"} + </PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Aspect ratio</PropertyItemLabel> + <PropertyItemValue> + <Select + value={getDisplayName()} + onValueChange={handleAspectRatioChange} + > + <SelectTrigger className="bg-panel-accent"> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + {canvasPresets.map((preset) => ( + <SelectItem key={preset.name} value={preset.name}> + {preset.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Frame rate</PropertyItemLabel> + <PropertyItemValue> + <Select + value={(activeProject?.fps || 30).toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="bg-panel-accent"> + <SelectValue placeholder="Select a frame rate" /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map((preset) => ( + <SelectItem key={preset.value} value={preset.value}> + {preset.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( + <div + className={cn( + "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary relative overflow-hidden", + isSelected && "border-2 border-primary" + )} + onClick={onSelect} + > + <Image + src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + alt={`Blur preview ${blur.label}`} + fill + className="object-cover" + style={{ filter: `blur(${blur.value}px)` }} + loading="eager" + /> + <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> + ) +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( + <div + key={bg} + className={cn( + "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary", + isColorBackground && + bg === currentBackgroundColor && + "border-2 border-primary" + )} + style={ + useBackgroundColor + ? { backgroundColor: bg } + : { + background: bg, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + } + onClick={() => handleColorSelect(bg)} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ] + ); + } +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +function BackgroundView() { + const { activeProject, updateBackgroundType } = useProjectStore(); + + const blurLevels = useMemo( + () => [ + { label: "Light", value: 4 }, + { label: "Medium", value: 8 }, + { label: "Heavy", value: 18 }, + ], + [] + ); + + const handleBlurSelect = useCallback( + async (blurIntensity: number) => { + await updateBackgroundType("blur", { blurIntensity }); + }, + [updateBackgroundType] + ); + + const handleColorSelect = useCallback( + async (color: string) => { + await updateBackgroundType("color", { backgroundColor: color }); + }, + [updateBackgroundType] + ); + + const currentBlurIntensity = activeProject?.blurIntensity || 8; + const isBlurBackground = activeProject?.backgroundType === "blur"; + const currentBackgroundColor = activeProject?.backgroundColor || "#000000"; + const isColorBackground = activeProject?.backgroundType === "color"; + + const blurPreviews = useMemo( + () => + blurLevels.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect(blur.value)} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] + ); + + return ( + <div className="flex flex-col gap-5"> + <PropertyGroup title="Blur" defaultExpanded={false}> + <div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div> + </PropertyGroup> + + <PropertyGroup title="Colors" defaultExpanded={false}> + <div className="grid grid-cols-4 gap-2 w-full"> + <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> + <BackgroundPreviews + backgrounds={colors} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={handleColorSelect} + useBackgroundColor={true} + /> + </div> + </PropertyGroup> + + <PropertyGroup title="Pattern Craft" defaultExpanded={false}> + <div className="grid grid-cols-4 gap-2 w-full"> + <BackgroundPreviews + backgrounds={patternCraftGradients} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={handleColorSelect} + /> + </div> + </PropertyGroup> + + <PropertyGroup title="Syntax UI" defaultExpanded={false}> + <div className="grid grid-cols-4 gap-2 w-full"> + <BackgroundPreviews + backgrounds={syntaxUIGradients} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={handleColorSelect} + /> + </div> + </PropertyGroup> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx new file mode 100644 index 00000000..ed0db8ab --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { useState, useMemo, useRef, useEffect } from "react"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + PlayIcon, + PauseIcon, + HeartIcon, + PlusIcon, + ListFilter, +} from "lucide-react"; +import { useSoundsStore } from "@/stores/sounds-store"; +import { useSoundSearch } from "@/hooks/use-sound-search"; +import type { SoundEffect, SavedSound } from "@/types/sounds"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export function SoundsView() { + return ( + <div className="h-full flex flex-col"> + <Tabs defaultValue="sound-effects" className="flex flex-col h-full"> + <div className="px-3 pt-4 pb-0"> + <TabsList> + <TabsTrigger value="sound-effects">Sound effects</TabsTrigger> + <TabsTrigger value="songs">Songs</TabsTrigger> + <TabsTrigger value="saved">Saved</TabsTrigger> + </TabsList> + </div> + <Separator className="my-4" /> + <TabsContent + value="sound-effects" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <SoundEffectsView /> + </TabsContent> + <TabsContent + value="saved" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <SavedSoundsView /> + </TabsContent> + <TabsContent + value="songs" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <SongsView /> + </TabsContent> + </Tabs> + </div> + ); +} + +function SoundEffectsView() { + const { + topSoundEffects, + isLoading, + searchQuery, + setSearchQuery, + scrollPosition, + setScrollPosition, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + showCommercialOnly, + toggleCommercialFilter, + } = useSoundsStore(); + const { + results: searchResults, + isLoading: isSearching, + loadMore, + hasNextPage, + isLoadingMore, + } = useSoundSearch(searchQuery, showCommercialOnly); + + // Audio playback state + const [playingId, setPlayingId] = useState<number | null>(null); + const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( + null + ); + + // Scroll position persistence + const scrollAreaRef = useRef<HTMLDivElement>(null); + + // Load saved sounds and restore scroll position when component mounts + useEffect(() => { + loadSavedSounds(); + + if (scrollAreaRef.current && scrollPosition > 0) { + const timeoutId = setTimeout(() => { + scrollAreaRef.current?.scrollTo({ top: scrollPosition }); + }, 100); // Small delay to ensure content is rendered + + return () => clearTimeout(timeoutId); + } + }, []); // Only run on mount + + // Track scroll position changes and handle infinite scroll + const handleScroll = (event: React.UIEvent<HTMLDivElement>) => { + const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + setScrollPosition(scrollTop); + + // Trigger loadMore when scrolled to within 200px of bottom + const nearBottom = scrollTop + clientHeight >= scrollHeight - 200; + if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) { + loadMore(); + } + }; + + // Use your existing design, just swap the data source + const displayedSounds = useMemo(() => { + const sounds = searchQuery ? searchResults : topSoundEffects; + return sounds; + }, [searchQuery, searchResults, topSoundEffects]); + + const playSound = (sound: SoundEffect) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + // Stop previous sound + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", (e) => { + setPlayingId(null); + }); + audio.play().catch((error) => { + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + return ( + <div className="flex flex-col gap-5 mt-1 h-full"> + <div className="flex items-center gap-3"> + <Input + placeholder="Search sound effects" + className="bg-panel-accent w-full" + containerClassName="w-full" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + showClearIcon + onClear={() => setSearchQuery("")} + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="icon" + className={cn(showCommercialOnly && "text-primary")} + > + <ListFilter className="w-4 h-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuCheckboxItem + checked={showCommercialOnly} + onCheckedChange={toggleCommercialFilter} + > + Show only commercially licensed + </DropdownMenuCheckboxItem> + <div className="px-2 py-1.5 text-xs text-muted-foreground"> + {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} + </div> + </DropdownMenuContent> + </DropdownMenu> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea + className="flex-1 h-full" + ref={scrollAreaRef} + onScrollCapture={handleScroll} + > + <div className="flex flex-col gap-4"> + {isLoading && !searchQuery && ( + <div className="text-muted-foreground text-sm"> + Loading sounds... + </div> + )} + {isSearching && searchQuery && ( + <div className="text-muted-foreground text-sm">Searching...</div> + )} + {displayedSounds.map((sound) => ( + <AudioItem + key={sound.id} + sound={sound} + isPlaying={playingId === sound.id} + onPlay={() => playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => toggleSavedSound(sound)} + /> + ))} + {!isLoading && !isSearching && displayedSounds.length === 0 && ( + <div className="text-muted-foreground text-sm"> + {searchQuery ? "No sounds found" : "No sounds available"} + </div> + )} + {isLoadingMore && ( + <div className="text-muted-foreground text-sm text-center py-4"> + Loading more sounds... + </div> + )} + </div> + </ScrollArea> + </div> + </div> + ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + clearSavedSounds, + } = useSoundsStore(); + + // Audio playback state + const [playingId, setPlayingId] = useState<number | null>(null); + const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( + null + ); + + // Clear confirmation dialog state + const [showClearDialog, setShowClearDialog] = useState(false); + + // Load saved sounds when tab becomes active + useEffect(() => { + loadSavedSounds(); + }, [loadSavedSounds]); + + const playSound = (sound: SavedSound) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + // Stop previous sound + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", (e) => { + setPlayingId(null); + }); + audio.play().catch((error) => { + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + // Convert SavedSound to SoundEffect for compatibility with AudioItem + const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({ + id: savedSound.id, + name: savedSound.name, + description: "", + url: "", + previewUrl: savedSound.previewUrl, + downloadUrl: savedSound.downloadUrl, + duration: savedSound.duration, + filesize: 0, + type: "audio", + channels: 0, + bitrate: 0, + bitdepth: 0, + samplerate: 0, + username: savedSound.username, + tags: savedSound.tags, + license: savedSound.license, + created: savedSound.savedAt, + downloads: 0, + rating: 0, + ratingCount: 0, + }); + + if (isLoadingSavedSounds) { + return ( + <div className="flex items-center justify-center h-full"> + <div className="text-muted-foreground text-sm"> + Loading saved sounds... + </div> + </div> + ); + } + + if (savedSoundsError) { + return ( + <div className="flex items-center justify-center h-full"> + <div className="text-destructive text-sm"> + Error: {savedSoundsError} + </div> + </div> + ); + } + + if (savedSounds.length === 0) { + return ( + <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3"> + <HeartIcon + className="w-10 h-10 text-muted-foreground" + strokeWidth={1.5} + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">No saved sounds</p> + <p className="text-sm text-muted-foreground text-balance"> + Click the heart icon on any sound to save it here + </p> + </div> + </div> + ); + } + + return ( + <div className="flex flex-col gap-5 mt-1 h-full"> + <div className="flex items-center justify-between"> + <p className="text-sm text-muted-foreground"> + {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} + </p> + <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}> + <DialogTrigger asChild> + <Button + variant="text" + size="sm" + className="h-auto text-muted-foreground hover:text-destructive !opacity-100" + > + Clear all + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Clear all saved sounds?</DialogTitle> + <DialogDescription> + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="text" onClick={() => setShowClearDialog(false)}> + Cancel + </Button> + <Button + variant="destructive" + onClick={async () => { + await clearSavedSounds(); + setShowClearDialog(false); + }} + > + Clear all sounds + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea className="flex-1 h-full"> + <div className="flex flex-col gap-4"> + {savedSounds.map((sound) => ( + <AudioItem + key={sound.id} + sound={convertToSoundEffect(sound)} + isPlaying={playingId === sound.id} + onPlay={() => playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => + toggleSavedSound(convertToSoundEffect(sound)) + } + /> + ))} + </div> + </ScrollArea> + </div> + </div> + ); +} + +function SongsView() { + return <div>Songs</div>; +} + +interface AudioItemProps { + sound: SoundEffect; + isPlaying: boolean; + onPlay: () => void; + isSaved: boolean; + onToggleSaved: () => void; +} + +function AudioItem({ + sound, + isPlaying, + onPlay, + isSaved, + onToggleSaved, +}: AudioItemProps) { + const { addSoundToTimeline } = useSoundsStore(); + + const handleClick = () => { + onPlay(); + }; + + const handleSaveClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleSaved(); + }; + + const handleAddToTimeline = async (e: React.MouseEvent) => { + e.stopPropagation(); + await addSoundToTimeline(sound); + }; + + return ( + <div + className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer" + onClick={handleClick} + > + <div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0"> + <div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" /> + {isPlaying ? ( + <PauseIcon className="w-5 h-5" /> + ) : ( + <PlayIcon className="w-5 h-5" /> + )} + </div> + + <div className="flex-1 min-w-0 overflow-hidden"> + <p className="font-medium truncate text-sm">{sound.name}</p> + <span className="text-xs text-muted-foreground truncate block"> + {sound.username} + </span> + </div> + + <div className="flex items-center gap-3 pr-2"> + <Button + variant="text" + size="icon" + className="text-muted-foreground hover:text-foreground !opacity-100 w-auto" + onClick={handleAddToTimeline} + title="Add to timeline" + > + <PlusIcon className="w-4 h-4" /> + </Button> + <Button + variant="text" + size="icon" + className={`hover:text-foreground !opacity-100 w-auto ${ + isSaved + ? "text-red-500 hover:text-red-600" + : "text-muted-foreground" + }`} + onClick={handleSaveClick} + title={isSaved ? "Remove from saved" : "Save sound"} + > + <HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} /> + </Button> + </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 0686190e..ca8e88c9 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -32,7 +32,7 @@ export function TextView() { <DraggableMediaItem name="Default text" preview={ - <div className="flex items-center justify-center w-full h-full bg-accent rounded"> + <div className="flex items-center justify-center w-full h-full bg-panel-accent rounded"> <span className="text-xs select-none">Default text</span> </div> } diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 4cc35bb5..b9c03382 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,24 +5,15 @@ import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; 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, -} from "@/components/ui/dropdown-menu"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; -import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; @@ -234,6 +225,7 @@ export function PreviewPanel() { tracks.forEach((track) => { track.elements.forEach((element) => { + if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -389,7 +381,7 @@ export function PreviewPanel() { textDecoration: element.textDecoration, padding: "4px 8px", borderRadius: "2px", - whiteSpace: "nowrap", + whiteSpace: "pre-wrap", // Fallback for system fonts that don't have classes ...(fontClassName === "" && { fontFamily: element.fontFamily }), }} @@ -407,11 +399,11 @@ export function PreviewPanel() { 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" + className="absolute inset-0 bg-linear-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> + <p className="text-xs text-foreground">{element.name}</p> </div> </div> ); @@ -475,10 +467,10 @@ export function PreviewPanel() { return ( <> - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative"> <div ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0" > <div className="flex-1" /> {hasAnyElements ? ( @@ -488,7 +480,7 @@ export function PreviewPanel() { style={{ width: previewDimensions.width, height: previewDimensions.height, - backgroundColor: + background: activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", @@ -620,9 +612,9 @@ function FullscreenToolbar({ return ( <div data-toolbar - className="flex items-center gap-2 p-1 pt-2 w-full text-white" + className="flex items-center gap-2 p-1 pt-2 w-full text-foreground relative" > - <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90"> + <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-foreground/90"> <EditableTimecode time={currentTime} duration={totalDuration} @@ -630,7 +622,7 @@ function FullscreenToolbar({ fps={activeProject?.fps || 30} onTimeChange={seek} disabled={!hasAnyElements} - className="text-white/90 hover:bg-white/10" + className="text-foreground/90 hover:bg-white/10" /> <span className="opacity-50">/</span> <span> @@ -648,7 +640,7 @@ function FullscreenToolbar({ size="icon" onClick={skipBackward} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground" title="Skip backward 1s" > <SkipBack className="h-3 w-3" /> @@ -658,7 +650,7 @@ function FullscreenToolbar({ size="icon" onClick={toggle} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground hover:text-foreground/80" > {isPlaying ? ( <Pause className="h-3 w-3" /> @@ -671,7 +663,7 @@ function FullscreenToolbar({ size="icon" onClick={skipForward} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground hover:text-foreground/80" title="Skip forward 1s" > <SkipForward className="h-3 w-3" /> @@ -681,7 +673,7 @@ function FullscreenToolbar({ <div className="flex-1 flex items-center gap-2"> <div className={cn( - "relative h-1 rounded-full cursor-pointer flex-1 bg-white/20", + "relative h-1 rounded-full cursor-pointer flex-1 bg-foreground/20", !hasAnyElements && "opacity-50 cursor-not-allowed" )} onClick={hasAnyElements ? handleTimelineClick : undefined} @@ -690,13 +682,13 @@ function FullscreenToolbar({ > <div className={cn( - "absolute top-0 left-0 h-full rounded-full bg-white", + "absolute top-0 left-0 h-full rounded-full bg-foreground", !isDragging && "duration-100" )} style={{ width: `${progress}%` }} /> <div - className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20" + className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-xs bg-foreground border border-black/20" style={{ left: `${progress}%` }} /> </div> @@ -705,11 +697,11 @@ function FullscreenToolbar({ <Button variant="text" size="icon" - className="!size-4 text-white/80 hover:text-white" + className="size-4! text-foreground/80 hover:text-foreground" onClick={onToggleExpanded} title="Exit fullscreen (Esc)" > - <Expand className="!size-4" /> + <Expand className="size-4!" /> </Button> </div> ); @@ -743,14 +735,14 @@ function FullscreenPreview({ getTotalDuration: () => number; }) { return ( - <div className="fixed inset-0 z-[9999] flex flex-col"> + <div className="fixed inset-0 z-9999 flex flex-col"> <div className="flex-1 flex items-center justify-center bg-background"> <div className="relative overflow-hidden border border-border m-3" style={{ width: previewDimensions.width, height: previewDimensions.height, - backgroundColor: + background: activeProject?.backgroundType === "blur" ? "#1a1a1a" : activeProject?.backgroundColor || "#1a1a1a", @@ -775,7 +767,7 @@ function FullscreenPreview({ )} </div> </div> - <div className="p-4 bg-black"> + <div className="p-4 bg-background"> <FullscreenToolbar hasAnyElements={hasAnyElements} onToggleExpanded={toggleExpanded} @@ -806,25 +798,7 @@ function PreviewToolbar({ toggle: () => void; getTotalDuration: () => number; }) { - const { isPlaying, seek } = usePlaybackStore(); - const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - 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 handleOriginalSelect = () => { - const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeToOriginal(aspectRatio); - }; + const { isPlaying } = usePlaybackStore(); if (isExpanded) { return ( @@ -844,88 +818,30 @@ function PreviewToolbar({ return ( <div data-toolbar - className="flex items-end justify-between gap-2 p-1 pt-2 w-full" + className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white" > - <div> - <p - className={cn( - "text-[0.75rem] text-muted-foreground flex items-center gap-1 w-[10rem]", - !hasAnyElements && "opacity-50" - )} + <div className="flex items-center gap-2"> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0" > - <EditableTimecode - time={currentTime} - duration={getTotalDuration()} - format="HH:MM:SS:FF" - fps={activeProject?.fps || 30} - onTimeChange={seek} - disabled={!hasAnyElements} - /> - <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> + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> <Button variant="text" size="icon" - className="!size-4 text-muted-foreground" + className="size-4!" onClick={onToggleExpanded} title="Enter fullscreen" > - <Expand className="!size-4" /> + <Expand className="size-4!" /> </Button> </div> </div> diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 720c4a26..6c79327e 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -1,95 +1,22 @@ "use client"; -import { FPS_PRESETS } from "@/constants/timeline-constants"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import { useMediaStore } from "@/stores/media-store"; -import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; -import { Label } from "../../ui/label"; import { ScrollArea } from "../../ui/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../ui/select"; import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; import { TextProperties } from "./text-properties"; +import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { - 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 emptyView = ( - <div className="space-y-4 p-5"> - {/* Media Properties */} - <div className="flex flex-col gap-3"> - <PropertyItem direction="column"> - <PropertyItemLabel className="text-xs text-muted-foreground"> - Name: - </PropertyItemLabel> - <PropertyItemValue className="text-xs truncate"> - {activeProject?.name || ""} - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel className="text-xs text-muted-foreground"> - Aspect ratio: - </PropertyItemLabel> - <PropertyItemValue className="text-xs truncate"> - {getDisplayName()} - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel className="text-xs text-muted-foreground"> - Resolution: - </PropertyItemLabel> - <PropertyItemValue className="text-xs truncate"> - {`${canvasSize.width} × ${canvasSize.height}`} - </PropertyItemValue> - </PropertyItem> - <div className="flex flex-col gap-1"> - <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 }) => { + <> + {selectedElements.length > 0 ? ( + <ScrollArea className="h-full bg-panel rounded-sm"> + {selectedElements.map(({ trackId, elementId }) => { const track = tracks.find((t) => t.id === trackId); const element = track?.elements.find((e) => e.id === elementId); @@ -116,8 +43,28 @@ export function PropertiesPanel() { ); } return null; - }) - : emptyView} - </ScrollArea> + })} + </ScrollArea> + ) : ( + <EmptyView /> + )} + </> + ); +} + +function EmptyView() { + return ( + <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3"> + <SquareSlashIcon + className="w-10 h-10 text-muted-foreground" + strokeWidth={1.5} + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">It’s empty here</p> + <p className="text-sm text-muted-foreground text-balance"> + Click an element on the timeline to edit its properties + </p> + </div> + </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 804ffb39..dc6b6d74 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -1,4 +1,6 @@ import { cn } from "@/lib/utils"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; interface PropertyItemProps { direction?: "row" | "column"; @@ -17,7 +19,7 @@ export function PropertyItem({ "flex gap-2", direction === "row" ? "items-center justify-between gap-6" - : "flex-col gap-1", + : "flex-col gap-1.5", className )} > @@ -33,7 +35,11 @@ export function PropertyItemLabel({ children: React.ReactNode; className?: string; }) { - return <label className={cn("text-xs", className)}>{children}</label>; + return ( + <label className={cn("text-xs text-muted-foreground", className)}> + {children} + </label> + ); } export function PropertyItemValue({ @@ -43,5 +49,36 @@ export function PropertyItemValue({ children: React.ReactNode; className?: string; }) { - return <div className={cn("flex-1", className)}>{children}</div>; + return <div className={cn("flex-1 text-sm", className)}>{children}</div>; +} + +interface PropertyGroupProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; + className?: string; +} + +export function PropertyGroup({ + title, + children, + defaultExpanded = true, + className, +}: PropertyGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + <PropertyItem direction="column" className={cn("gap-3", className)}> + <div + className="flex items-center gap-1.5 cursor-pointer" + onClick={() => setIsExpanded(!isExpanded)} + > + <PropertyItemLabel className="cursor-pointer"> + {title} + </PropertyItemLabel> + <ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} /> + </div> + {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} + </PropertyItem> + ); } 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 f8380ff4..a06624eb 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -91,7 +91,7 @@ export function TextProperties({ <Textarea placeholder="Name" defaultValue={element.content} - className="min-h-[4.5rem] resize-none bg-background/50" + className="min-h-18 resize-none bg-background/50" onChange={(e) => updateTextElement(trackId, element.id, { content: e.target.value }) } diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index 0a97c6f8..ca9d799f 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -49,10 +49,7 @@ export function SelectionBox({ return ( <div ref={selectionBoxRef} - className="absolute pointer-events-none z-50" - style={{ - backgroundColor: "hsl(var(--foreground) / 0.1)", - }} + className="absolute pointer-events-none z-50 bg-foreground/10" /> ); } diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 053d119e..0a51e107 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -65,7 +65,7 @@ export function SnapIndicator({ return ( <div - className="absolute pointer-events-none z-[90]" + className="absolute pointer-events-none z-90" style={{ left: `${leftPosition}px`, top: 0, diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 0d9660bb..bff0a90a 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { ScrollArea } from "../../ui/scroll-area"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "../../ui/button"; import { Scissors, @@ -21,6 +21,10 @@ import { Link, ZoomIn, ZoomOut, + Bookmark, + Eye, + MicOff, + Mic, } from "lucide-react"; import { Tooltip, @@ -545,7 +549,7 @@ export function Timeline() { {/* 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-panel border-r flex items-center justify-between px-3 py-2"> + <div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> {/* Empty space */} <span className="text-sm font-medium text-muted-foreground opacity-0"> . @@ -653,6 +657,30 @@ export function Timeline() { ); }).filter(Boolean); })()} + + {/* Bookmark markers */} + {(() => { + const { activeProject } = useProjectStore.getState(); + if (!activeProject?.bookmarks?.length) return null; + + return activeProject.bookmarks.map((bookmarkTime, i) => ( + <div + key={`bookmark-${i}`} + className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" + style={{ + left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + onClick={(e) => { + e.stopPropagation(); + usePlaybackStore.getState().seek(bookmarkTime); + }} + > + <div className="absolute top-[-1px] left-[-5px] text-primary"> + <Bookmark className="h-3 w-3 fill-primary" /> + </div> + </div> + )); + })()} </div> </ScrollArea> </div> @@ -664,7 +692,7 @@ export function Timeline() { {tracks.length > 0 && ( <div ref={trackLabelsRef} - className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel" + className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel" data-track-labels > <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> @@ -672,17 +700,24 @@ export function Timeline() { {tracks.map((track) => ( <div key={track.id} - className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + className="flex items-center px-3 group" style={{ height: `${getTrackHeight(track.type)}px` }} > - <div className="flex items-center flex-1 min-w-0"> + <div className="flex items-center justify-end flex-1 min-w-0 gap-2"> + {track.muted ? ( + <MicOff + className="h-4 w-4 text-destructive cursor-pointer" + onClick={() => toggleTrackMute(track.id)} + /> + ) : ( + <Mic + className="h-4 w-4 text-muted-foreground cursor-pointer" + onClick={() => toggleTrackMute(track.id)} + /> + )} + <Eye className="h-4 w-4 text-muted-foreground" /> <TrackIcon track={track} /> </div> - {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> - Muted - </span> - )} </div> ))} </div> @@ -713,12 +748,7 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - <ScrollArea - className="w-full h-full" - ref={tracksScrollRef} - type="scroll" - showHorizontalScrollbar - > + <ScrollArea className="w-full h-full" ref={tracksScrollRef}> <div className="relative flex-1" style={{ @@ -737,7 +767,7 @@ export function Timeline() { <ContextMenu key={track.id}> <ContextMenuTrigger asChild> <div - className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + className="absolute left-0 right-0" style={{ top: `${getCumulativeHeightBefore( tracks, @@ -763,7 +793,7 @@ export function Timeline() { /> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-[200]"> + <ContextMenuContent className="z-200"> <ContextMenuItem onClick={(e) => { e.stopPropagation(); @@ -793,13 +823,13 @@ function TrackIcon({ track }: { track: TimelineTrack }) { return ( <> {track.type === "media" && ( - <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <Video className="w-4 h-4 shrink-0 text-muted-foreground" /> )} {track.type === "text" && ( - <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" /> )} {track.type === "audio" && ( - <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + <Music className="w-4 h-4 shrink-0 text-muted-foreground" /> )} </> ); @@ -832,6 +862,7 @@ function TimelineToolbar({ toggleRippleEditing, } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { toggleBookmark, isBookmarked } = useProjectStore(); // Action handlers const handleSplitSelected = () => { @@ -961,6 +992,13 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + // Check if the current time is bookmarked + const currentBookmarked = isBookmarked(currentTime); return ( <div className="border-b flex items-center justify-between px-2 py-1"> <div className="flex items-center gap-1 w-full"> @@ -1108,6 +1146,19 @@ function TimelineToolbar({ </TooltipTrigger> <TooltipContent>Delete element (Delete)</TooltipContent> </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleToggleBookmark}> + <Bookmark + className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + </TooltipContent> + </Tooltip> </TooltipProvider> </div> <div className="flex items-center gap-1"> @@ -1141,6 +1192,8 @@ function TimelineToolbar({ </TooltipContent> </Tooltip> </TooltipProvider> + + <div className="h-6 w-px bg-border mx-1" /> <div className="flex items-center gap-1"> <Button variant="text" size="icon" onClick={handleZoomOut}> <ZoomOut className="h-4 w-4" /> diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index f581bbf0..b26a14bb 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,25 +1,21 @@ "use client"; -import { useState } from "react"; -import { Button } from "../../ui/button"; import { - MoreVertical, Scissors, Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, Copy, RefreshCw, + EyeOff, + Eye, + Volume2, + VolumeX, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps, TrackType } from "@/types/timeline"; +import { TimelineElementProps } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, @@ -27,16 +23,6 @@ import { getTrackHeight, } from "@/constants/timeline-constants"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "../../ui/dropdown-menu"; -import { ContextMenu, ContextMenuContent, ContextMenuItem, @@ -60,30 +46,27 @@ export function TimelineElement({ removeElementFromTrackWithRipple, dragState, splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, addElementToTrack, replaceElementMedia, rippleEditingEnabled, + toggleElementHidden, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const [elementMenuOpen, setElementMenuOpen] = useState(false); + const mediaItem = + element.type === "media" + ? mediaItems.find((item) => item.id === element.mediaId) + : null; + const isAudio = mediaItem?.type === "audio"; - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); + const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -141,6 +124,11 @@ export function TimelineElement({ } }; + const handleToggleElementHidden = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleElementHidden(track.id, element.id); + }; + const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); if (element.type !== "media") { @@ -177,9 +165,7 @@ export function TimelineElement({ 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> + <span className="text-xs text-white truncate">{element.content}</span> </div> ); } @@ -196,86 +182,36 @@ export function TimelineElement({ const TILE_ASPECT_RATIO = 16 / 9; - if (mediaItem.type === "image") { + if ( + mediaItem.type === "image" || + (mediaItem.type === "video" && mediaItem.thumbnailUrl) + ) { // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding + const tileHeight = trackHeight; 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( - 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> - ); - } - - 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 - 8; // Match image padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const imageUrl = + mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; + const isImage = mediaItem.type === "image"; return ( <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled thumbnails */} + <div + className={`w-full h-full relative ${ + isSelected ? "bg-primary" : "bg-transparent" + }`} + > <div - className="absolute top-3 bottom-3 left-0 right-0" + className={`absolute top-[0.15rem] bottom-[0.15rem] left-0 right-0`} style={{ - backgroundImage: mediaItem.thumbnailUrl - ? `url(${mediaItem.thumbnailUrl})` - : "none", + backgroundImage: imageUrl ? `url(${imageUrl})` : "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 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", - }} + aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} /> </div> </div> @@ -330,9 +266,9 @@ export function TimelineElement({ <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" : ""} ${ + )} ${isSelected ? "" : ""} ${ isBeingDragged ? "z-50" : "z-10" - }`} + } ${element.hidden ? "opacity-50" : ""}`} onClick={(e) => onElementClick && onElementClick(e, element)} onMouseDown={handleElementMouseDown} onContextMenu={(e) => @@ -343,14 +279,24 @@ export function TimelineElement({ {renderElementContent()} </div> + {element.hidden && ( + <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> + {isAudio ? ( + <VolumeX className="h-6 w-6 text-white" /> + ) : ( + <EyeOff className="h-6 w-6 text-white" /> + )} + </div> + )} + {isSelected && ( <> <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + className="absolute left-0 top-0 bottom-0 w-[0.2rem] cursor-w-resize bg-primary 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" + className="absolute right-0 top-0 bottom-0 w-[0.2rem] cursor-e-resize bg-primary z-50" onMouseDown={(e) => handleResizeStart(e, element.id, "right")} /> </> @@ -358,11 +304,34 @@ export function TimelineElement({ </div> </div> </ContextMenuTrigger> - <ContextMenuContent className="z-[200]"> + <ContextMenuContent className="z-200"> <ContextMenuItem onClick={handleElementSplitContext}> <Scissors className="h-4 w-4 mr-2" /> Split at playhead </ContextMenuItem> + <ContextMenuItem onClick={handleToggleElementHidden}> + {isAudio ? ( + element.hidden ? ( + <Volume2 className="h-4 w-4 mr-2" /> + ) : ( + <VolumeX className="h-4 w-4 mr-2" /> + ) + ) : element.hidden ? ( + <Eye className="h-4 w-4 mr-2" /> + ) : ( + <EyeOff className="h-4 w-4 mr-2" /> + )} + <span> + {isAudio + ? element.hidden + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {element.type === "text" ? "text" : "clip"} + </span> + </ContextMenuItem> <ContextMenuItem onClick={handleElementDuplicateContext}> <Copy className="h-4 w-4 mr-2" /> Duplicate {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index dbbd18df..248c6176 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -70,7 +70,7 @@ export function TimelinePlayhead({ // 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 + const totalHeight = timelineContainerHeight - 4; // Get dynamic track labels width, fallback to 0 if no tracks or no ref const trackLabelsWidth = @@ -126,7 +126,7 @@ export function TimelinePlayhead({ return ( <div ref={playheadRef} - className="absolute pointer-events-auto z-[150]" + className="absolute pointer-events-auto z-150" style={{ left: `${leftPosition}px`, top: 0, @@ -137,12 +137,12 @@ export function TimelinePlayhead({ > {/* The playhead line spanning full height */} <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} + className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "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"}`} + className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} /> </div> ); diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 097fc051..1982d03e 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -68,45 +68,51 @@ export function TimelineTrackContent({ 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); - } + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = 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 - ); + // Additionally apply element snapping if enabled + if (snappingEnabled) { + // 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 - ); + 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; + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + } } - return bestSnapResult.snappedTime; + return finalTime; }; const timelineRef = useRef<HTMLDivElement>(null); @@ -148,9 +154,13 @@ export function TimelineTrackContent({ ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - // Apply snapping if enabled - let finalTime = adjustedTime; + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(adjustedTime, projectFps); let snapPoint = null; + + // Additionally apply element snapping if enabled if (snappingEnabled) { // Find the element being dragged to get its duration let elementDuration = 5; // fallback duration @@ -196,18 +206,16 @@ export function TimelineTrackContent({ bestSnapResult = endSnapResult; } - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + 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 + // Clear snap point when element snapping is disabled onSnapPointChange?.(null); } diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 96d4705f..d4d121d9 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -93,10 +93,10 @@ const EditableShortcutKey = ({ <Button variant="text" size="sm" - 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 ${ + className={`inline-flex font-sans text-xs rounded px-2 min-w-6 min-h-6 leading-none items-center justify-center shadow-xs border mr-1 cursor-pointer hover:bg-opacity-80 ${ isRecording ? "border-primary bg-primary/10" - : "border-white/10 bg-black/20" + : "border bg-accent" }`} onClick={handleClick} title={ diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx index e87d50e9..3ed39a9f 100644 --- a/apps/web/src/components/landing/handlebars.tsx +++ b/apps/web/src/components/landing/handlebars.tsx @@ -46,9 +46,9 @@ export function Handlebars({ children }: HandlebarsProps) { const rightGradient = useTransform(rightHandleX, [0, width + 10], [0, 100]); return ( - <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> - <div ref={containerRef} className="relative -rotate-[2.76deg] mt-2"> - <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-[1]"> + <div className="flex justify-center gap-4 leading-16"> + <div ref={containerRef} className="relative -rotate-[2.76deg] mt-0.5"> + <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-1"> <motion.div className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none" style={{ diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index dba958ac..2c6a130e 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -30,7 +30,7 @@ export function Hero() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6, duration: 0.8 }} - className="mb-8 flex justify-center" + className="mb-4 flex justify-center" > <SponsorButton href="https://vercel.com/home?utm_source=opencut" diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index d72ac0f6..36ae68d9 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -79,7 +79,7 @@ export function Onboarding() { return ( <Dialog open={isOpen} onOpenChange={handleClose}> - <DialogContent className="sm:max-w-[425px] !outline-none"> + <DialogContent className="sm:max-w-[425px] outline-hidden!"> <DialogTitle> <span className="sr-only">{getStepTitle()}</span> </DialogTitle> diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts new file mode 100644 index 00000000..2d7c684c --- /dev/null +++ b/apps/web/src/components/providers/global-prefetcher.ts @@ -0,0 +1,78 @@ +"use client";
+
+import { useEffect } from "react";
+import { useSoundsStore } from "@/stores/sounds-store";
+
+export function useGlobalPrefetcher() {
+ const {
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+
+ useEffect(() => {
+ if (hasLoaded) return;
+
+ let ignore = false;
+
+ const prefetchTopSounds = async () => {
+ try {
+ if (!ignore) {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads"
+ );
+
+ if (!ignore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects(data.results);
+ setHasLoaded(true);
+
+ // Set pagination state for top sounds
+ setCurrentPage(1);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ }
+ } catch (error) {
+ if (!ignore) {
+ console.error("Failed to prefetch top sounds:", error);
+ setError(
+ error instanceof Error ? error.message : "Failed to load sounds"
+ );
+ }
+ } finally {
+ if (!ignore) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(prefetchTopSounds, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+}
diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index c50f9f71..e2b01be0 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -51,7 +51,7 @@ export function RenameProjectDialog({ } }} placeholder="Enter a new name" - className="mt-4" + className="mt-4 bg-background/50" /> <DialogFooter> diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index e37530f4..02b8532b 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -4,16 +4,16 @@ 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", + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden 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", + "border-transparent bg-primary text-primary-foreground shadow-sm 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", + "border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80", outline: "text-foreground", }, }, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 5902af65..d3656790 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,20 +5,21 @@ 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", + "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden 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", + default: + "bg-foreground text-background shadow-sm hover:bg-foreground/90", primary: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", + "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-xs 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) + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx index 82d77752..a469c87a 100644 --- a/apps/web/src/components/ui/chart.tsx +++ b/apps/web/src/components/ui/chart.tsx @@ -57,7 +57,7 @@ const ChartContainer = React.forwardRef< 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", + "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-hidden [&_.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-hidden [&_.recharts-surface]:outline-hidden", className )} {...props} @@ -184,7 +184,7 @@ const ChartTooltipContent = React.forwardRef< <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", + "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", className )} > @@ -213,7 +213,7 @@ const ChartTooltipContent = React.forwardRef< !hideIndicator && ( <div className={cn( - "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", { "h-2.5 w-2.5": indicator === "dot", "w-1": indicator === "line", diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index 2b74bc52..5e429994 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -13,7 +13,7 @@ const Checkbox = React.forwardRef< <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", + "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow-sm focus-visible:outline-hidden 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} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 11b27041..887c1ab7 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -44,7 +44,7 @@ const CommandInput = React.forwardRef< <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", + "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className )} {...props} @@ -115,7 +115,7 @@ const CommandItem = React.forwardRef< <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", + "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden 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} diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index 7aa938f6..a1a28fa6 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -20,7 +20,7 @@ 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", + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { @@ -64,7 +64,7 @@ const ContextMenuSubContent = React.forwardRef< <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", + "z-50 min-w-32 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} @@ -80,7 +80,7 @@ const ContextMenuContent = React.forwardRef< <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", + "z-50 min-w-32 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 )} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 35843a5f..2d98285b 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef< <DialogPrimitive.Overlay ref={ref} className={cn( - "fixed inset-0 z-[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", + "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} @@ -39,7 +39,7 @@ const DialogContent = React.forwardRef< <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%] rounded-lg", + "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 rounded-lg", className )} onCloseAutoFocus={(e) => { @@ -55,7 +55,7 @@ const DialogContent = React.forwardRef< <ScrollArea className="max-h-[75vh]"> <div className="p-6 space-y-4">{children}</div> </ScrollArea> - <DialogPrimitive.Close className="absolute right-4 top-4 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"> + <DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <X className="h-4 w-4" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index dfb4dce7..ff6766fd 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -24,6 +24,7 @@ export interface DraggableMediaItemProps { showPlusOnDrag?: boolean; showLabel?: boolean; rounded?: boolean; + variant?: "card" | "compact"; } export function DraggableMediaItem({ @@ -37,6 +38,7 @@ export function DraggableMediaItem({ showPlusOnDrag = true, showLabel = true, rounded = true, + variant = "card", }: DraggableMediaItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); @@ -88,49 +90,69 @@ export function DraggableMediaItem({ 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} + {variant === "card" ? ( + <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-panel-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> + ) : ( + <div ref={dragRef} className="relative group w-full"> + <div className={cn( - "bg-accent relative overflow-hidden", - rounded && "rounded-md", - "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding + "h-10 flex items-center gap-3 cursor-default w-full", + "[&::-webkit-drag-ghost]:opacity-0", + className )} 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 className="w-6 h-6 flex-shrink-0 rounded overflow-hidden"> + {preview} + </div> + <span className="text-sm truncate flex-1 w-full">{name}</span> + </div> </div> - </div> + )} {/* Custom drag preview */} {isDragging && typeof document !== "undefined" && createPortal( <div - className="fixed pointer-events-none z-[9999]" + 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) @@ -139,7 +161,7 @@ export function DraggableMediaItem({ <div className="w-[80px]"> <AspectRatio ratio={1} - className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary" + className="relative rounded-md overflow-hidden shadow-2xl ring-3 ring-primary" > <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> {preview} @@ -171,7 +193,10 @@ function PlusButton({ const button = ( <Button size="icon" - className={cn("absolute bottom-2 right-2 size-4", className)} + className={cn( + "absolute bottom-2 right-2 size-4 bg-background text-foreground", + className + )} onClick={(e) => { e.preventDefault(); e.stopPropagation(); @@ -179,7 +204,7 @@ function PlusButton({ }} title={tooltipText} > - <Plus className="!size-3" /> + <Plus className="size-3!" /> </Button> ); diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index d9962dd2..823d48a3 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,7 +20,7 @@ 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", + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { @@ -65,7 +65,7 @@ const DropdownMenuSubContent = React.forwardRef< <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", + "z-50 min-w-32 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} @@ -87,7 +87,7 @@ const DropdownMenuContent = React.forwardRef< e.preventDefault(); }} className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", + "z-50 min-w-32 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 )} @@ -126,20 +126,24 @@ const DropdownMenuCheckboxItem = React.forwardRef< ref={ref} className={cn( dropdownMenuItemVariants({ variant }), - "pl-8 pr-2", + "pl-2 pr-8", className )} checked={checked} + onSelect={(e) => { + e.preventDefault(); + }} {...props} > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + {children} + <span className="absolute right-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; diff --git a/apps/web/src/components/ui/hover-card.tsx b/apps/web/src/components/ui/hover-card.tsx index c3958fcf..92700e09 100644 --- a/apps/web/src/components/ui/hover-card.tsx +++ b/apps/web/src/components/ui/hover-card.tsx @@ -18,7 +18,7 @@ const HoverCardContent = React.forwardRef< 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", + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx index 1bffc451..0d2cc002 100644 --- a/apps/web/src/components/ui/input-otp.tsx +++ b/apps/web/src/components/ui/input-otp.tsx @@ -13,7 +13,7 @@ const InputOTP = React.forwardRef< <OTPInput ref={ref} containerClassName={cn( - "flex items-center justify-between gap-2 w-full has-[:disabled]:opacity-50", + "flex items-center justify-between gap-2 w-full has-disabled:opacity-50", containerClassName )} className={cn("disabled:cursor-not-allowed", className)} @@ -45,7 +45,7 @@ const InputOTPSlot = React.forwardRef< <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", + "relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-xs transition-all first:rounded-l-md first:border-l last:rounded-r-md", isActive && "z-10 ring-1 ring-ring", className )} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 8fc31a58..440191f2 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, X } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "./button"; @@ -7,37 +7,93 @@ import { Button } from "./button"; interface InputProps extends React.ComponentProps<"input"> { showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; + showClearIcon?: boolean; + onClear?: () => void; + containerClassName?: string; } const Input = React.forwardRef<HTMLInputElement, InputProps>( ( - { className, type, showPassword, onShowPasswordChange, value, ...props }, + { + className, + type, + containerClassName, + showPassword, + onShowPasswordChange, + showClearIcon, + onClear, + value, + onFocus, + onBlur, + ...props + }, ref ) => { + const [isFocused, setIsFocused] = React.useState(false); + const isPassword = type === "password"; const showPasswordToggle = isPassword && onShowPasswordChange; + const showClear = + showClearIcon && + onClear && + value && + String(value).length > 0 && + isFocused; const inputType = isPassword && showPassword ? "text" : type; + const hasIcons = showPasswordToggle || showClear; + const iconCount = Number(showPasswordToggle) + Number(showClear); + const paddingRight = + iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; + return ( - <div className={showPassword ? "relative w-full" : ""}> + <div className={cn(hasIcons ? "relative w-full" : "", containerClassName)}> <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", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + paddingRight, className )} ref={ref} value={value} + onFocus={(e) => { + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} {...props} /> + {showClear && ( + <Button + type="button" + variant="text" + size="icon" + onMouseDown={(e) => { + e.preventDefault(); + onClear?.(); + }} + className="absolute right-0 top-0 h-full px-3 text-muted-foreground !opacity-100" + aria-label="Clear input" + > + <X className="!size-[0.85]" /> + </Button> + )} {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" + className={cn( + "absolute top-0 h-full px-3 text-muted-foreground hover:text-foreground", + showClear ? "right-10" : "right-0" + )} aria-label={showPassword ? "Hide password" : "Show password"} > {showPassword ? ( diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx index 642ffa2b..96e66c8a 100644 --- a/apps/web/src/components/ui/menubar.tsx +++ b/apps/web/src/components/ui/menubar.tsx @@ -23,7 +23,7 @@ const Menubar = React.forwardRef< <MenubarPrimitive.Root ref={ref} className={cn( - "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", + "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-xs", className )} {...props} @@ -38,7 +38,7 @@ const MenubarTrigger = React.forwardRef< <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", + "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className )} {...props} @@ -55,7 +55,7 @@ const MenubarSubTrigger = React.forwardRef< <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", + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className )} @@ -74,7 +74,7 @@ const MenubarSubContent = React.forwardRef< <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", + "z-50 min-w-32 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} @@ -97,7 +97,7 @@ const MenubarContent = React.forwardRef< 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", + "z-50 min-w-48 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} @@ -116,7 +116,7 @@ const MenubarItem = React.forwardRef< <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", + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", inset && "pl-8", className )} @@ -132,7 +132,7 @@ const MenubarCheckboxItem = React.forwardRef< <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", + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", className )} checked={checked} @@ -155,7 +155,7 @@ const MenubarRadioItem = React.forwardRef< <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", + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50", className )} {...props} diff --git a/apps/web/src/components/ui/navigation-menu.tsx b/apps/web/src/components/ui/navigation-menu.tsx index b5dd3622..288b5d88 100644 --- a/apps/web/src/components/ui/navigation-menu.tsx +++ b/apps/web/src/components/ui/navigation-menu.tsx @@ -41,7 +41,7 @@ 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-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50" ); const NavigationMenuTrigger = React.forwardRef< @@ -55,7 +55,7 @@ const NavigationMenuTrigger = React.forwardRef< > {children}{" "} <ChevronDown - className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" + className="relative top-px ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" aria-hidden="true" /> </NavigationMenuPrimitive.Trigger> @@ -86,7 +86,7 @@ const NavigationMenuViewport = React.forwardRef< <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)]", + "origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-(--radix-navigation-menu-viewport-width)", className )} ref={ref} @@ -104,7 +104,7 @@ const NavigationMenuIndicator = React.forwardRef< <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", + "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} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index cc4f6508..47782a7f 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-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", + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx index cc07d34f..859b8b5f 100644 --- a/apps/web/src/components/ui/radio-group.tsx +++ b/apps/web/src/components/ui/radio-group.tsx @@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef< <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", + "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow-sm focus:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", className )} {...props} diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index a4443996..fc9d8b81 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -28,7 +28,7 @@ const ResizableHandle = ({ }) => ( <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", + "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-hidden 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} diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 5c9db727..1fc227b0 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -1,53 +1,18 @@ -"use client"; - import * as React from "react"; -import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; - -import { cn } from "../../lib/utils"; +import { cn } from "@/lib/utils"; const ScrollArea = React.forwardRef< - React.ElementRef<typeof ScrollAreaPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { - type?: "auto" | "always" | "scroll" | "hover"; - showHorizontalScrollbar?: boolean; - } ->(({ className, children, type, showHorizontalScrollbar, ...props }, ref) => ( - <ScrollAreaPrimitive.Root - ref={ref} - className={cn("relative overflow-hidden", className)} - type={type} - {...props} - > - <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> - {children} - </ScrollAreaPrimitive.Viewport> - <ScrollBar /> - {showHorizontalScrollbar && <ScrollBar orientation="horizontal" />} - <ScrollAreaPrimitive.Corner /> - </ScrollAreaPrimitive.Root> -)); -ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; - -const ScrollBar = React.forwardRef< - React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> ->(({ className, orientation = "vertical", ...props }, ref) => ( - <ScrollAreaPrimitive.ScrollAreaScrollbar + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, children, ...props }, ref) => ( + <div 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 - )} + className={cn("overflow-auto scrollbar-thin", className)} {...props} > - <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> - </ScrollAreaPrimitive.ScrollAreaScrollbar> + {children} + </div> )); -ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; +ScrollArea.displayName = "ScrollArea"; -export { ScrollArea, ScrollBar }; +export { ScrollArea }; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 911b3e5d..53ca27cb 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -14,7 +14,7 @@ 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", + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50", { variants: { variant: { @@ -35,14 +35,14 @@ const SelectTrigger = React.forwardRef< <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", + "flex gap-1 h-7 cursor-pointer w-auto items-center justify-between whitespace-nowrap rounded-md bg-accent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-hidden 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" /> + <ChevronDown className="size-3 opacity-50" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> )); @@ -91,7 +91,7 @@ const SelectContent = React.forwardRef< <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", + "relative z-50 max-h-96 min-w-32 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 @@ -108,7 +108,7 @@ const SelectContent = React.forwardRef< className={cn( "p-1", position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)" )} > {children} diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx index 5b6774dc..f0040a52 100644 --- a/apps/web/src/components/ui/separator.tsx +++ b/apps/web/src/components/ui/separator.tsx @@ -19,7 +19,7 @@ const Separator = React.forwardRef< orientation={orientation} className={cn( "shrink-0 bg-border", - orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className )} {...props} diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 6aa092dc..2c5522c1 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -64,7 +64,7 @@ const SheetContent = React.forwardRef< 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"> + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> <X className="h-4 w-4" /> <span className="sr-only">Close</span> </SheetPrimitive.Close> diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index c1583503..819116c3 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -141,7 +141,7 @@ const SidebarProvider = React.forwardRef< } as React.CSSProperties } className={cn( - "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", + "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className )} ref={ref} @@ -181,7 +181,7 @@ const Sidebar = React.forwardRef< return ( <div className={cn( - "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", + "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground", className )} ref={ref} @@ -198,7 +198,7 @@ const Sidebar = React.forwardRef< <SheetContent data-sidebar="sidebar" data-mobile="true" - className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" style={ { "--sidebar-width": SIDEBAR_WIDTH_MOBILE, @@ -225,30 +225,30 @@ const Sidebar = React.forwardRef< {/* 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", + "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]" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--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", + "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]", + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--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" + 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-sm" > {children} </div> @@ -301,9 +301,9 @@ const SidebarRail = React.forwardRef< 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", + "in-data-[side=left]:cursor-w-resize in-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", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", className @@ -323,7 +323,7 @@ const SidebarInset = React.forwardRef< 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", + "peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm", className )} {...props} @@ -439,7 +439,7 @@ const SidebarGroupLabel = React.forwardRef< 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", + "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-hidden 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 )} @@ -460,9 +460,9 @@ const SidebarGroupAction = React.forwardRef< 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", + "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden 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", + "after:absolute after:-inset-2 md:after:hidden", "group-data-[collapsible=icon]:hidden", className )} @@ -512,18 +512,18 @@ const SidebarMenuItem = React.forwardRef< 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", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden 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))]", + "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_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", + lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", }, }, defaultVariants: { @@ -606,9 +606,9 @@ const SidebarMenuAction = React.forwardRef< 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", + "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden 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", + "after:absolute after:-inset-2 md:after: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", @@ -669,7 +669,7 @@ const SidebarMenuSkeleton = React.forwardRef< /> )} <Skeleton - className="h-4 flex-1 max-w-[--skeleton-width]" + className="h-4 flex-1 max-w-(--skeleton-width)" data-sidebar="menu-skeleton-text" style={ { @@ -722,7 +722,7 @@ const SidebarMenuSubButton = React.forwardRef< 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", + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden 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", diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 4789ae17..d4afdd06 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -20,7 +20,7 @@ const Slider = React.forwardRef< <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.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow-sm transition-colors focus-visible:outline-hidden 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/sponsor-button.tsx b/apps/web/src/components/ui/sponsor-button.tsx index a060e428..9dc8e966 100644 --- a/apps/web/src/components/ui/sponsor-button.tsx +++ b/apps/web/src/components/ui/sponsor-button.tsx @@ -19,7 +19,7 @@ export function SponsorButton({ 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}`} + className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-xs 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 diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index ba324f1d..f210afdf 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ 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", + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden 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} diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx index 6b83618c..7481cabb 100644 --- a/apps/web/src/components/ui/table.tsx +++ b/apps/web/src/components/ui/table.tsx @@ -43,7 +43,7 @@ const TableFooter = React.forwardRef< <tfoot ref={ref} className={cn( - "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", + "border-t bg-muted/50 font-medium last:[&>tr]:border-b-0", className )} {...props} diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index a51a28a2..ea628959 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -14,7 +14,7 @@ const TabsList = React.forwardRef< <TabsPrimitive.List ref={ref} className={cn( - "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + "inline-flex h-auto items-center justify-center gap-2 rounded-lg bg-transparent p-0 text-muted-foreground", className )} {...props} @@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef< <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", + "inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground", className )} {...props} @@ -44,7 +44,7 @@ const TabsContent = React.forwardRef< <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", + "mt-2 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className )} {...props} diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index 64f81ce6..b9004a34 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,7 +9,7 @@ const Textarea = React.forwardRef< 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", + "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", className )} ref={ref} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index bf89e350..bd733f63 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef< <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]", + "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} @@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef< 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", + "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-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--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 sm:data-[state=open]:slide-in-from-bottom-full", { variants: { variant: { @@ -62,7 +62,7 @@ const ToastAction = React.forwardRef< <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", + "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-hidden focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive", className )} {...props} @@ -77,7 +77,7 @@ const ToastClose = React.forwardRef< <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", + "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground hover:group-[.destructive]:text-red-50 focus-visible:group-[.destructive]:ring-red-400 focus-visible:group-[.destructive]:ring-offset-red-600", className )} toast-close="" diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index a4bfda42..460aa9f2 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -7,13 +7,13 @@ 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", + "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-hidden 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", + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", }, size: { default: "h-9 px-2 min-w-9", |
