From df1e80b5287ff71867bad1a1bfebb102cef7d5bf Mon Sep 17 00:00:00 2001 From: Brandon McConnell Date: Wed, 30 Jul 2025 13:15:09 -0400 Subject: Lay groundwork for Tailwind v4 upgrade --- apps/web/src/components/editor/media-panel/tabbar.tsx | 6 +++--- apps/web/src/components/editor/media-panel/views/media.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index bce84a46..92482559 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -82,7 +82,7 @@ export function TabBar() { onClick={() => setActiveTab(tabKey)} key={tabKey} > - + {tab.label} ); @@ -114,10 +114,10 @@ function ScrollButton({
); 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..d1533736 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -169,7 +169,7 @@ export function MediaView() { if (item.type === "audio") { return ( -
+
Audio {item.duration && ( -- cgit v1.3.1 From 68da487f304018b6e8d06459def8fb45514ab7d4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 1 Aug 2025 19:57:26 +0200 Subject: style: complete re-design of whole editor --- apps/web/src/app/editor/[project_id]/page.tsx | 6 +- apps/web/src/app/globals.css | 83 ++-- apps/web/src/app/layout.tsx | 2 +- apps/web/src/components/editor-header.tsx | 36 +- .../src/components/editor/media-panel/index.tsx | 6 +- .../src/components/editor/media-panel/tabbar.tsx | 5 +- .../components/editor/media-panel/views/audio.tsx | 1 + .../components/editor/media-panel/views/media.tsx | 507 +++++++++++++++------ .../components/editor/media-panel/views/text.tsx | 2 +- apps/web/src/components/editor/preview-panel.tsx | 157 +------ apps/web/src/components/editor/timeline/index.tsx | 61 ++- .../editor/timeline/timeline-element.tsx | 142 ++---- .../editor/timeline/timeline-playhead.tsx | 6 +- .../web/src/components/keyboard-shortcuts-help.tsx | 2 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/draggable-item.tsx | 85 ++-- apps/web/src/components/ui/input.tsx | 4 +- apps/web/src/constants/timeline-constants.ts | 16 +- apps/web/src/stores/panel-store.ts | 6 + 19 files changed, 625 insertions(+), 504 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 5e294fe7..03579144 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -167,7 +167,7 @@ export default function Editor() { {/* Main content area */} {/* Tools Panel */} @@ -214,7 +214,7 @@ export default function Editor() { minSize={15} maxSize={70} onResize={setTimeline} - className="min-h-0 px-2 pb-2" + className="min-h-0 px-3 pb-3" > diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 96fef9b0..d612e27e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -9,38 +9,40 @@ :root { /* Custom colors - light mode (default) */ - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 96.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 89.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); - --sidebar-background: hsl(0 0% 100%); - --sidebar-foreground: hsl(0 0% 3.9%); - --sidebar-primary: hsl(0 0% 9%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 96.1%); - --sidebar-accent-foreground: hsl(0 0% 9%); - --sidebar-border: hsl(0 0% 89.8%); - --sidebar-ring: hsl(0 0% 3.9%); + --background: hsl(0, 0%, 100%); + --foreground: hsl(0 0% 11%); + --card: hsl(0 0% 85.1%); + --card-foreground: hsl(0 0% 2%); + --popover: hsl(0, 0%, 100%); + --popover-foreground: hsl(0 0% 2%); + --primary: hsl(205, 84%, 47%); + --primary-foreground: hsl(0 0% 91%); + --secondary: hsl(216, 13%, 92%); + --secondary-foreground: hsl(0 0% 2%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 36.1%); + --accent: hsl(216, 13%, 92%); + --accent-foreground: hsl(0 0% 2%); + --destructive: hsl(0 100% 40%); + --destructive-foreground: hsl(0, 0%, 100%); + --border: hsl(0 0% 83%); + --input: hsl(0 0% 85.1%); + --ring: hsl(0, 0%, 55%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar-background: hsl(0 0% 96.1%); + --sidebar-foreground: hsl(0 0% 2%); + --sidebar-primary: hsl(0 0% 2%); + --sidebar-primary-foreground: hsl(0 0% 91%); + --sidebar-accent: hsl(0 0% 85.1%); + --sidebar-accent-foreground: hsl(0 0% 2%); + --sidebar-border: hsl(0 0% 85.1%); + --sidebar-ring: hsl(0 0% 16.9%); + --panel-background: hsl(216 13% 92%); + --panel-accent: hsl(216, 8%, 86%); /* Radius base */ --radius: 1rem; @@ -224,3 +226,20 @@ display: none; } } + +@utility scrollbar-thin { + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5c1d7520..a64c4440 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ - + {children} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 99cea3e8..77202c36 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,7 +1,14 @@ "use client"; import { Button } from "./ui/button"; -import { ChevronDown, ArrowLeft, Download, SquarePen, Trash } 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"; @@ -19,14 +26,18 @@ import Link from "next/link"; import { RenameProjectDialog } from "./rename-project-dialog"; import { DeleteProjectDialog } from "./delete-project-dialog"; import { useRouter } from "next/navigation"; -import { FaDiscord, FaGithub } from "react-icons/fa6"; +import { FaDiscord } from "react-icons/fa6"; +import { useTheme } from "next-themes"; +import { usePlaybackStore } from "@/stores/playback-store"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); + 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 @@ -120,7 +131,11 @@ export function EditorHeader() { const centerContent = (
- + + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + + / + {formatTimeCode( getTotalDuration(), "HH:MM:SS:FF", @@ -135,11 +150,20 @@ export function EditorHeader() { + ); @@ -149,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/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 359612f8..a5cd6686 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -5,6 +5,7 @@ import { MediaView } from "./views/media"; import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; import { AudioView } from "./views/audio"; +import { Separator } from "@/components/ui/separator"; export function MediaPanel() { const { activeTab } = useMediaPanelStore(); @@ -46,9 +47,10 @@ export function MediaPanel() { }; return ( -
+
-
{viewMap[activeTab]}
+ +
{viewMap[activeTab]}
); } diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 92482559..a3733e4d 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -61,7 +61,7 @@ export function TabBar() { }, []); return ( -
+
{(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; @@ -83,7 +83,6 @@ export function TabBar() { key={tabKey} > - {tab.label}
); })} diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx index ba64d749..0d3c3f1e 100644 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -9,6 +9,7 @@ export function AudioView() {
setSearch(e.target.value)} /> diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index d1533736..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; +}) { + return ( + + {children} + + Export clips + onRemove(e, item.id)} + > + Delete + + + + ); +} export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); + const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef(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 ( -
- {item.name} -
- ); - } + const previewComponents = useMemo(() => { + const previews = new Map(); - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
+ filteredMediaItems.forEach((item) => { + let preview: React.ReactNode; + + if (item.type === "image") { + preview = ( +
{item.name} -
-
+ ); + } else if (item.type === "video") { + if (item.thumbnailUrl) { + preview = ( +
+ {item.name} +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
+ ); + } else { + preview = ( +
+
+ ); + } + } else if (item.type === "audio") { + preview = ( +
+ + Audio {item.duration && ( -
+ {formatDuration(item.duration)} -
+ )}
); + } else { + preview = ( +
+ + Unknown +
+ ); } - return ( -
-
- ); - } - if (item.type === "audio") { - return ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } + previews.set(item.id, preview); + }); - return ( -
- - Unknown -
- ); - }; + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaItem) => previewComponents.get(item.id); return ( <> @@ -207,43 +290,152 @@ export function MediaView() { >
{/* Search and filter controls */} -
- - setSearchQuery(e.target.value)} - /> +
+
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
+ + + + + + + + + { + if (sortBy === "name") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("name"); + setSortOrder("asc"); + } + }} + > + Name{" "} + {sortBy === "name" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "type") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("type"); + setSortOrder("asc"); + } + }} + > + Type{" "} + {sortBy === "type" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "duration") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("duration"); + setSortOrder("asc"); + } + }} + > + Duration{" "} + {sortBy === "duration" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "size") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("size"); + setSortOrder("asc"); + } + }} + > + File Size{" "} + {sortBy === "size" && + (sortOrder === "asc" ? "↑" : "↓")} + + + + +

+ Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) +

+
+
+
+
+
- -
+
+
{isDragOver || filteredMediaItems.length === 0 ? ( + ) : mediaViewMode === "grid" ? ( + ) : ( -
- {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - - - - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - - - Export clips - handleRemove(e, item.id)} - > - Delete - - - - ))} -
+ )}
- +
); } + +function GridView({ + filteredMediaItems, + renderPreview, + handleRemove, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; +}) { + return ( +
+ {filteredMediaItems.map((item) => ( + + + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + rounded={false} + variant="card" + /> + + ))} +
+ ); +} + +function ListView({ + filteredMediaItems, + renderPreview, + handleRemove, + formatDuration, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; + formatDuration: (duration: number) => string; +}) { + return ( +
+ {filteredMediaItems.map((item) => ( + + + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + variant="compact" + /> + + ))} +
+ ); +} 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() { +
Default text
} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index c6f4e159..3a962025 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"; @@ -412,7 +403,7 @@ export function PreviewPanel() { >
🎬
-

{element.name}

+

{element.name}

); @@ -476,10 +467,10 @@ export function PreviewPanel() { return ( <> -
+
{hasAnyElements ? ( @@ -621,9 +612,9 @@ function FullscreenToolbar({ return (
-
+
/ @@ -649,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" > @@ -659,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 ? ( @@ -672,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" > @@ -682,7 +673,7 @@ function FullscreenToolbar({
@@ -706,7 +697,7 @@ function FullscreenToolbar({
-
+
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 totalDuration = getTotalDuration(); - - const skipBackward = () => { - const newTime = Math.max(0, currentTime - 1); - setCurrentTime(newTime); - }; - - const skipForward = () => { - const newTime = Math.min(totalDuration, currentTime + 1); - setCurrentTime(newTime); - }; + const { isPlaying } = usePlaybackStore(); if (isExpanded) { return ( @@ -857,50 +818,15 @@ function PreviewToolbar({ return (
-
-

- - / - - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - -

-
-
- +
-
-
- - - - - - - - Original - - - {canvasPresets.map((preset) => ( - handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - - ))} - - - diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index c7ed833e..b26a14bb 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,16 +1,8 @@ "use client"; -import { useState } from "react"; -import { Button } from "../../ui/button"; import { - MoreVertical, Scissors, Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, Copy, RefreshCw, EyeOff, @@ -23,23 +15,13 @@ 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, TIMELINE_CONSTANTS, getTrackHeight, } from "@/constants/timeline-constants"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "../../ui/dropdown-menu"; import { ContextMenu, ContextMenuContent, @@ -64,9 +46,6 @@ export function TimelineElement({ removeElementFromTrackWithRipple, dragState, splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, addElementToTrack, replaceElementMedia, rippleEditingEnabled, @@ -74,27 +53,20 @@ export function TimelineElement({ } = 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; @@ -193,9 +165,7 @@ export function TimelineElement({ if (element.type === "text") { return (
- - {element.content} - + {element.content}
); } @@ -212,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 ( -
-
- {/* Background with tiled images */} -
- {/* Overlay with vertical borders */} -
-
-
- ); - } - - 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 (
-
- {/* Background with tiled thumbnails */} +
- {/* Overlay with vertical borders */} -
@@ -346,7 +266,7 @@ export function TimelineElement({
onElementClick && onElementClick(e, element)} @@ -372,11 +292,11 @@ export function TimelineElement({ {isSelected && ( <>
handleResizeStart(e, element.id, "left")} />
handleResizeStart(e, element.id, "right")} /> @@ -407,8 +327,8 @@ export function TimelineElement({ ? "Unmute" : "Mute" : element.hidden - ? "Show" - : "Hide"}{" "} + ? "Show" + : "Hide"}{" "} {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 ea70419b..695843a4 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 = @@ -137,12 +137,12 @@ export function TimelinePlayhead({ > {/* The playhead line spanning full height */}
{/* Playhead dot indicator at the top (in ruler area) */}
); diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 15b1e1d8..d4d121d9 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -96,7 +96,7 @@ const EditableShortcutKey = ({ 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/ui/button.tsx b/apps/web/src/components/ui/button.tsx index e5c5d4cc..d3656790 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -19,7 +19,7 @@ const buttonVariants = cva( "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) + 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/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 4c71e4b7..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,42 +90,62 @@ export function DraggableMediaItem({ return ( <> -
-
- +
+ + {preview} + {!isDragging && ( + + )} + + {showLabel && ( + + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + + )} +
+
+ ) : ( +
+
- {preview} - {!isDragging && ( - - )} - - {showLabel && ( - - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - - )} +
+ {preview} +
+ {name} +
-
+ )} {/* Custom drag preview */} {isDragging && @@ -171,7 +193,10 @@ function PlusButton({ const button = (
), + settings: , }; return ( diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index c661f51e..577417a9 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -9,6 +9,7 @@ import { SlidersHorizontalIcon, LucideIcon, TypeIcon, + SettingsIcon, } from "lucide-react"; import { create } from "zustand"; @@ -21,7 +22,8 @@ export type Tab = | "transitions" | "captions" | "filters" - | "adjustment"; + | "adjustment" + | "settings"; export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { media: { @@ -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/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx new file mode 100644 index 00000000..54387dd7 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -0,0 +1,246 @@ +"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, +} 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"; +import { PipetteIcon } from "lucide-react"; +import { useMemo, memo, useCallback } from "react"; + +export function SettingsView() { + return ; +} + +function ProjectSettingsTabs() { + return ( +
+ +
+ + Project info + Background + +
+ + + + + + + + + +
+
+ ); +} + +function ProjectInfoView() { + const { activeProject, updateProjectFps } = useProjectStore(); + const { canvasSize, 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 ( +
+ + Name + + {activeProject?.name || "Untitled project"} + + + + + Aspect ratio + + + + + + + Frame rate + + + + +
+ ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( +
+ {`Blur +
+ + {blur.label} + +
+
+ ) +); + +BlurPreview.displayName = "BlurPreview"; + +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) => ( + handleBlurSelect(blur.value)} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] + ); + + const colorPreviews = useMemo( + () => + colors.map((color) => ( +
handleColorSelect(color)} + /> + )), + [isColorBackground, currentBackgroundColor, handleColorSelect] + ); + + return ( +
+ + Blur + +
{blurPreviews}
+
+
+ + Color + +
+
+ +
+ {colorPreviews} +
+
+
+
+ ); +} 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..86a8d378 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -17,7 +17,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 +33,11 @@ export function PropertyItemLabel({ children: React.ReactNode; className?: string; }) { - return ; + return ( + + ); } export function PropertyItemValue({ @@ -43,5 +47,5 @@ export function PropertyItemValue({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return
{children}
; } diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 43f953e8..065a123d 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -163,3 +163,25 @@ export function DataBuddyIcon({ ); } + +export function SquareSlashIcon({ + className, + size = 24, +}: { + className?: string; + size?: number; +}) { + return ( + + + + + ); +} diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index ce4f340d..53ca27cb 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -35,14 +35,14 @@ const SelectTrigger = React.forwardRef< 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} - + )); diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index e5384daf..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< Date: Sat, 2 Aug 2025 12:21:24 +0200 Subject: feat: make settings items expandable --- .../editor/media-panel/views/settings.tsx | 34 ++++++++++------------ .../editor/properties-panel/property-item.tsx | 33 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 19 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index 54387dd7..cdfa36f2 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -14,6 +14,7 @@ import { PropertyItem, PropertyItemLabel, PropertyItemValue, + PropertyGroup, } from "../../properties-panel/property-item"; import { FPS_PRESETS } from "@/constants/timeline-constants"; import { useProjectStore } from "@/stores/project-store"; @@ -55,7 +56,7 @@ function ProjectSettingsTabs() { function ProjectInfoView() { const { activeProject, updateProjectFps } = useProjectStore(); - const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore(); + const { canvasPresets, setCanvasSize } = useEditorStore(); const { getDisplayName } = useAspectRatio(); const handleAspectRatioChange = (value: string) => { @@ -147,7 +148,7 @@ const BlurPreview = memo( fill className="object-cover" style={{ filter: `blur(${blur.value}px)` }} - priority + loading="eager" />
@@ -223,24 +224,19 @@ function BackgroundView() { ); return ( -
- - Blur - -
{blurPreviews}
-
-
- - Color - -
-
- -
- {colorPreviews} +
+ +
{blurPreviews}
+
+ + +
+
+
- - + {colorPreviews} +
+
); } 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 86a8d378..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"; @@ -49,3 +51,34 @@ export function PropertyItemValue({ }) { return
{children}
; } + +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 ( + +
setIsExpanded(!isExpanded)} + > + + {title} + + +
+ {isExpanded && {children}} +
+ ); +} -- cgit v1.3.1 From 14393c559da7e5d402d8fad5e4959919160fc293 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 12:23:30 +0200 Subject: fix: grammar --- apps/web/src/components/editor/media-panel/views/settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index cdfa36f2..8667b9d1 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -229,7 +229,7 @@ function BackgroundView() {
{blurPreviews}
- +
-- cgit v1.3.1 From e5a5b76ece6c09ddfbb761bdafd7c01e762bc5b3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 12:37:21 +0200 Subject: style: add hover effect to tabs --- apps/web/src/components/editor/media-panel/tabbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index a3733e4d..dcf5c8e7 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -76,8 +76,8 @@ export function TabBar() { return (
setActiveTab(tabKey)} key={tabKey} -- cgit v1.3.1 From 2c0423667cf3c8fedb194c7b34b5edd5657628f4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 16:09:20 +0200 Subject: feat: background gradients to project settings --- apps/web/src/components/background-settings.tsx | 2 +- .../editor/media-panel/views/settings.tsx | 111 ++++++++-- apps/web/src/components/editor/preview-panel.tsx | 4 +- apps/web/src/data/colors.ts | 244 --------------------- apps/web/src/data/colors/pattern-craft.ts | 30 +++ apps/web/src/data/colors/solid.ts | 244 +++++++++++++++++++++ apps/web/src/data/colors/syntax-ui.tsx | 32 +++ 7 files changed, 396 insertions(+), 271 deletions(-) delete mode 100644 apps/web/src/data/colors.ts create mode 100644 apps/web/src/data/colors/pattern-craft.ts create mode 100644 apps/web/src/data/colors/solid.ts create mode 100644 apps/web/src/data/colors/syntax-ui.tsx (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index 7e27e574..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"; diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index 8667b9d1..3bce1753 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -22,9 +22,11 @@ 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"; +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 ; @@ -137,8 +139,8 @@ const BlurPreview = memo( }) => (
@@ -161,6 +163,57 @@ const BlurPreview = memo( 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) => ( +
handleColorSelect(bg)} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ] + ); + } +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + function BackgroundView() { const { activeProject, updateBackgroundType } = useProjectStore(); @@ -205,36 +258,46 @@ function BackgroundView() { [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] ); - const colorPreviews = useMemo( - () => - colors.map((color) => ( -
handleColorSelect(color)} - /> - )), - [isColorBackground, currentBackgroundColor, handleColorSelect] - ); - return (
- +
{blurPreviews}
- +
- {colorPreviews} + +
+
+ + +
+ +
+
+ + +
+
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 3a962025..58cc5d5b 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -480,7 +480,7 @@ export function PreviewPanel() { style={{ width: previewDimensions.width, height: previewDimensions.height, - backgroundColor: + background: activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", @@ -742,7 +742,7 @@ function FullscreenPreview({ style={{ width: previewDimensions.width, height: previewDimensions.height, - backgroundColor: + background: activeProject?.backgroundType === "blur" ? "#1a1a1a" : activeProject?.backgroundColor || "#1a1a1a", diff --git a/apps/web/src/data/colors.ts b/apps/web/src/data/colors.ts deleted file mode 100644 index 9a12a587..00000000 --- a/apps/web/src/data/colors.ts +++ /dev/null @@ -1,244 +0,0 @@ -export const colors = [ - "#ffffff", - "#000000", - "#ffe2e2", - "#ffc9c9", - "#ffa2a2", - "#ff6467", - "#fb2c36", - "#e7000b", - "#c10007", - "#9f0712", - "#82181a", - "#460809", - "#fff7ed", - "#ffedd4", - "#ffd6a7", - "#ffb86a", - "#ff8904", - "#ff6900", - "#f54900", - "#ca3500", - "#9f2d00", - "#7e2a0c", - "#441306", - "#fffbeb", - "#fef3c6", - "#fee685", - "#ffd230", - "#ffb900", - "#fe9a00", - "#e17100", - "#bb4d00", - "#973c00", - "#7b3306", - "#461901", - "#fefce8", - "#fef9c2", - "#fff085", - "#ffdf20", - "#fdc700", - "#f0b100", - "#d08700", - "#a65f00", - "#894b00", - "#733e0a", - "#432004", - "#f7fee7", - "#ecfcca", - "#d8f999", - "#bbf451", - "#9ae600", - "#7ccf00", - "#5ea500", - "#497d00", - "#3c6300", - "#35530e", - "#192e03", - "#f0fdf4", - "#dcfce7", - "#b9f8cf", - "#7bf1a8", - "#05df72", - "#00c950", - "#00a63e", - "#008236", - "#016630", - "#0d542b", - "#032e15", - "#ecfdf5", - "#d0fae5", - "#a4f4cf", - "#5ee9b5", - "#00d492", - "#00bc7d", - "#009966", - "#007a55", - "#006045", - "#004f3b", - "#002c22", - "#f0fdfa", - "#cbfbf1", - "#96f7e4", - "#46ecd5", - "#00d5be", - "#00bba7", - "#009689", - "#00786f", - "#005f5a", - "#0b4f4a", - "#022f2e", - "#ecfeff", - "#cefafe", - "#a2f4fd", - "#53eafd", - "#00d3f2", - "#00b8db", - "#0092b8", - "#007595", - "#005f78", - "#104e64", - "#053345", - "#f0f9ff", - "#dff2fe", - "#b8e6fe", - "#74d4ff", - "#00bcff", - "#00a6f4", - "#0084d1", - "#0069a8", - "#00598a", - "#024a70", - "#052f4a", - "#eff6ff", - "#dbeafe", - "#bedbff", - "#8ec5ff", - "#51a2ff", - "#2b7fff", - "#155dfc", - "#1447e6", - "#193cb8", - "#1c398e", - "#162456", - "#eef2ff", - "#e0e7ff", - "#c6d2ff", - "#a3b3ff", - "#7c86ff", - "#615fff", - "#4f39f6", - "#432dd7", - "#372aac", - "#312c85", - "#1e1a4d", - "#f5f3ff", - "#ede9fe", - "#ddd6ff", - "#c4b4ff", - "#a684ff", - "#8e51ff", - "#7f22fe", - "#7008e7", - "#5d0ec0", - "#4d179a", - "#2f0d68", - "#faf5ff", - "#f3e8ff", - "#e9d4ff", - "#dab2ff", - "#c27aff", - "#ad46ff", - "#9810fa", - "#8200db", - "#6e11b0", - "#59168b", - "#3c0366", - "#fdf4ff", - "#fae8ff", - "#f6cfff", - "#f4a8ff", - "#ed6aff", - "#e12afb", - "#c800de", - "#a800b7", - "#8a0194", - "#721378", - "#4b004f", - "#fdf2f8", - "#fce7f3", - "#fccee8", - "#fda5d5", - "#fb64b6", - "#f6339a", - "#e60076", - "#c6005c", - "#a3004c", - "#861043", - "#510424", - "#fff1f2", - "#ffe4e6", - "#ffccd3", - "#ffa1ad", - "#ff637e", - "#ff2056", - "#ec003f", - "#c70036", - "#a50036", - "#8b0836", - "#4d0218", - "#f8fafc", - "#f1f5f9", - "#e2e8f0", - "#cad5e2", - "#90a1b9", - "#62748e", - "#45556c", - "#314158", - "#1d293d", - "#0f172b", - "#020618", - "#f9fafb", - "#f3f4f6", - "#e5e7eb", - "#d1d5dc", - "#99a1af", - "#6a7282", - "#4a5565", - "#364153", - "#1e2939", - "#101828", - "#030712", - "#fafafa", - "#f4f4f5", - "#e4e4e7", - "#d4d4d8", - "#9f9fa9", - "#71717b", - "#52525c", - "#3f3f46", - "#27272a", - "#18181b", - "#09090b", - "#f5f5f5", - "#e5e5e5", - "#d4d4d4", - "#a1a1a1", - "#737373", - "#525252", - "#404040", - "#262626", - "#171717", - "#0a0a0a", - "#fafaf9", - "#f5f5f4", - "#e7e5e4", - "#d6d3d1", - "#a6a09b", - "#79716b", - "#57534d", - "#44403b", - "#292524", - "#1c1917", - "#0c0a09", -]; diff --git a/apps/web/src/data/colors/pattern-craft.ts b/apps/web/src/data/colors/pattern-craft.ts new file mode 100644 index 00000000..56c49b34 --- /dev/null +++ b/apps/web/src/data/colors/pattern-craft.ts @@ -0,0 +1,30 @@ +// These are the gradients from Pattern Craft (https://patterncraft.fun/) + +export const patternCraftGradients = [ + // Dreamy Sky Pink Glow + "radial-gradient(circle at 30% 70%, rgba(173, 216, 230, 0.35), transparent 60%), radial-gradient(circle at 70% 30%, rgba(255, 182, 193, 0.4), transparent 60%)", + + // Soft Warm Pastel Texture + "radial-gradient(circle at 20% 80%, rgba(255, 182, 153, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 244, 214, 0.5) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba(255, 182, 153, 0.1) 0%, transparent 50%), #fff8f0", + + // Warm Soft Coral & Cream + "radial-gradient(circle at 20% 80%, rgba(255, 160, 146, 0.25) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 244, 228, 0.3) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba(255, 160, 146, 0.15) 0%, transparent 50%), #fef9f7", + + // Soft Green Glow + "radial-gradient(circle at center, #8FFFB0, transparent), white", + + // Purple Glow Right + "radial-gradient(circle at top right, rgba(173, 109, 244, 0.5), transparent 70%)", + + // Teal Glow Right + "radial-gradient(circle at top right, rgba(56, 193, 182, 0.5), transparent 70%)", + + // Warm Orange Glow Right + "radial-gradient(circle at top right, rgba(255, 140, 60, 0.5), transparent 70%)", + + // Cool Blue Glow Right + "radial-gradient(circle at top right, rgba(70, 130, 180, 0.5), transparent 70%)", + + // Purple Glow Left + "radial-gradient(circle at top left, rgba(173, 109, 244, 0.5), transparent 70%)", +]; diff --git a/apps/web/src/data/colors/solid.ts b/apps/web/src/data/colors/solid.ts new file mode 100644 index 00000000..9a12a587 --- /dev/null +++ b/apps/web/src/data/colors/solid.ts @@ -0,0 +1,244 @@ +export const colors = [ + "#ffffff", + "#000000", + "#ffe2e2", + "#ffc9c9", + "#ffa2a2", + "#ff6467", + "#fb2c36", + "#e7000b", + "#c10007", + "#9f0712", + "#82181a", + "#460809", + "#fff7ed", + "#ffedd4", + "#ffd6a7", + "#ffb86a", + "#ff8904", + "#ff6900", + "#f54900", + "#ca3500", + "#9f2d00", + "#7e2a0c", + "#441306", + "#fffbeb", + "#fef3c6", + "#fee685", + "#ffd230", + "#ffb900", + "#fe9a00", + "#e17100", + "#bb4d00", + "#973c00", + "#7b3306", + "#461901", + "#fefce8", + "#fef9c2", + "#fff085", + "#ffdf20", + "#fdc700", + "#f0b100", + "#d08700", + "#a65f00", + "#894b00", + "#733e0a", + "#432004", + "#f7fee7", + "#ecfcca", + "#d8f999", + "#bbf451", + "#9ae600", + "#7ccf00", + "#5ea500", + "#497d00", + "#3c6300", + "#35530e", + "#192e03", + "#f0fdf4", + "#dcfce7", + "#b9f8cf", + "#7bf1a8", + "#05df72", + "#00c950", + "#00a63e", + "#008236", + "#016630", + "#0d542b", + "#032e15", + "#ecfdf5", + "#d0fae5", + "#a4f4cf", + "#5ee9b5", + "#00d492", + "#00bc7d", + "#009966", + "#007a55", + "#006045", + "#004f3b", + "#002c22", + "#f0fdfa", + "#cbfbf1", + "#96f7e4", + "#46ecd5", + "#00d5be", + "#00bba7", + "#009689", + "#00786f", + "#005f5a", + "#0b4f4a", + "#022f2e", + "#ecfeff", + "#cefafe", + "#a2f4fd", + "#53eafd", + "#00d3f2", + "#00b8db", + "#0092b8", + "#007595", + "#005f78", + "#104e64", + "#053345", + "#f0f9ff", + "#dff2fe", + "#b8e6fe", + "#74d4ff", + "#00bcff", + "#00a6f4", + "#0084d1", + "#0069a8", + "#00598a", + "#024a70", + "#052f4a", + "#eff6ff", + "#dbeafe", + "#bedbff", + "#8ec5ff", + "#51a2ff", + "#2b7fff", + "#155dfc", + "#1447e6", + "#193cb8", + "#1c398e", + "#162456", + "#eef2ff", + "#e0e7ff", + "#c6d2ff", + "#a3b3ff", + "#7c86ff", + "#615fff", + "#4f39f6", + "#432dd7", + "#372aac", + "#312c85", + "#1e1a4d", + "#f5f3ff", + "#ede9fe", + "#ddd6ff", + "#c4b4ff", + "#a684ff", + "#8e51ff", + "#7f22fe", + "#7008e7", + "#5d0ec0", + "#4d179a", + "#2f0d68", + "#faf5ff", + "#f3e8ff", + "#e9d4ff", + "#dab2ff", + "#c27aff", + "#ad46ff", + "#9810fa", + "#8200db", + "#6e11b0", + "#59168b", + "#3c0366", + "#fdf4ff", + "#fae8ff", + "#f6cfff", + "#f4a8ff", + "#ed6aff", + "#e12afb", + "#c800de", + "#a800b7", + "#8a0194", + "#721378", + "#4b004f", + "#fdf2f8", + "#fce7f3", + "#fccee8", + "#fda5d5", + "#fb64b6", + "#f6339a", + "#e60076", + "#c6005c", + "#a3004c", + "#861043", + "#510424", + "#fff1f2", + "#ffe4e6", + "#ffccd3", + "#ffa1ad", + "#ff637e", + "#ff2056", + "#ec003f", + "#c70036", + "#a50036", + "#8b0836", + "#4d0218", + "#f8fafc", + "#f1f5f9", + "#e2e8f0", + "#cad5e2", + "#90a1b9", + "#62748e", + "#45556c", + "#314158", + "#1d293d", + "#0f172b", + "#020618", + "#f9fafb", + "#f3f4f6", + "#e5e7eb", + "#d1d5dc", + "#99a1af", + "#6a7282", + "#4a5565", + "#364153", + "#1e2939", + "#101828", + "#030712", + "#fafafa", + "#f4f4f5", + "#e4e4e7", + "#d4d4d8", + "#9f9fa9", + "#71717b", + "#52525c", + "#3f3f46", + "#27272a", + "#18181b", + "#09090b", + "#f5f5f5", + "#e5e5e5", + "#d4d4d4", + "#a1a1a1", + "#737373", + "#525252", + "#404040", + "#262626", + "#171717", + "#0a0a0a", + "#fafaf9", + "#f5f5f4", + "#e7e5e4", + "#d6d3d1", + "#a6a09b", + "#79716b", + "#57534d", + "#44403b", + "#292524", + "#1c1917", + "#0c0a09", +]; diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx new file mode 100644 index 00000000..6a7677b4 --- /dev/null +++ b/apps/web/src/data/colors/syntax-ui.tsx @@ -0,0 +1,32 @@ +// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients) + +export const syntaxUIGradients = [ + // Cyan to Blue gradients + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + "linear-gradient(to right, #bfdbfe, #a5f3fc)", + "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)", + + // Purple gradients + "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)", + "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)", + + // Blue gradients + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)", + + // Green gradients + "linear-gradient(to right, #6ee7b7, #34d399, #10b981)", + "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)", + + // Red gradient + "linear-gradient(to right, #fca5a5, #f87171, #ef4444)", + + // Yellow/Orange gradient + "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)", + + // Pink gradient + "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)", + + // Neon radial gradient + "radial-gradient(circle at bottom left, #ff00ff, #00ffff)", +]; -- cgit v1.3.1 From ae54aff1a9762ceb9e42a534c29c584bbf18d6d5 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 2 Aug 2025 16:12:50 +0200 Subject: style: padding in media panel tabs --- apps/web/src/components/editor/media-panel/tabbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index dcf5c8e7..fc4f677d 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -61,7 +61,7 @@ export function TabBar() { }, []); return ( -
+
{(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; -- cgit v1.3.1 From 48b694bbd875740bbdb350001bb688c2fbf4ad1f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 01:30:33 +0200 Subject: feat: sound effects functionality with freesound's api --- apps/web/.env.example | 6 +- apps/web/src/app/api/sounds/search/route.ts | 254 +++++++++++ apps/web/src/app/editor/[project_id]/layout.tsx | 13 + apps/web/src/app/globals.css | 2 +- .../src/components/editor/media-panel/index.tsx | 4 +- .../web/src/components/editor/media-panel/store.ts | 6 +- .../components/editor/media-panel/views/audio.tsx | 19 - .../components/editor/media-panel/views/sounds.tsx | 463 +++++++++++++++++++++ apps/web/src/components/editor/timeline/index.tsx | 9 +- .../src/components/providers/global-prefetcher.ts | 78 ++++ apps/web/src/components/ui/input.tsx | 62 ++- apps/web/src/env.ts | 4 + apps/web/src/hooks/use-sound-search.ts | 155 +++++++ apps/web/src/lib/storage/storage-service.ts | 92 ++++ apps/web/src/lib/storage/types.ts | 1 + apps/web/src/stores/sounds-store.ts | 273 ++++++++++++ apps/web/src/stores/timeline-store.ts | 22 +- apps/web/src/types/sounds.ts | 39 ++ 18 files changed, 1457 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/app/api/sounds/search/route.ts create mode 100644 apps/web/src/app/editor/[project_id]/layout.tsx delete mode 100644 apps/web/src/components/editor/media-panel/views/audio.tsx create mode 100644 apps/web/src/components/editor/media-panel/views/sounds.tsx create mode 100644 apps/web/src/components/providers/global-prefetcher.ts create mode 100644 apps/web/src/hooks/use-sound-search.ts create mode 100644 apps/web/src/stores/sounds-store.ts create mode 100644 apps/web/src/types/sounds.ts (limited to 'apps/web/src/components/editor/media-panel') diff --git a/apps/web/.env.example b/apps/web/.env.example index b21f9a92..af84fd77 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -16,4 +16,8 @@ UPSTASH_REDIS_REST_TOKEN=example_token # Marble Blog MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key -NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com \ No newline at end of file +NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com + +# Freesound (generate at https://freesound.org/apiv2/apply/) +FREESOUND_CLIENT_ID=... +FREESOUND_API_KEY=... \ No newline at end of file diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts new file mode 100644 index 00000000..15954140 --- /dev/null +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -0,0 +1,254 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { env } from "@/env"; +import { baseRateLimit } from "@/lib/rate-limit"; + +const searchParamsSchema = z.object({ + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), +}); + +const freesoundResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), +}); + +const freesoundResponseSchema = z.object({ + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), +}); + +const transformedResultSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), +}); + +const apiResponseSchema = z.object({ + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), +}); + +export async function GET(request: NextRequest) { + try { + const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; + const { success } = await baseRateLimit.limit(ip); + + if (!success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const { searchParams } = new URL(request.url); + + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); + + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + } = validationResult.data; + + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 } + ); + } + + const baseUrl = "https://freesound.org/apiv2/search/text/"; + + // Use score sorting for search queries, downloads for top sounds + const sortParam = query + ? sort === "score" + ? "score" + : `${sort}_desc` + : `${sort}_desc`; + + const params = new URLSearchParams({ + query: query || "", + token: env.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", + }); + + // Always apply sound effect filters (since we're primarily a sound effects search) + if (type === "effects" || !type) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + params.append( + "filter", + "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" + ); + } + + const response = await fetch(`${baseUrl}?${params.toString()}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status } + ); + } + + const rawData = await response.json(); + + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 } + ); + } + + const data = freesoundValidation.data; + + const transformedResults = data.results.map((result) => ({ + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + })); + + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; + + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 } + ); + } + + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx new file mode 100644 index 00000000..151f7f94 --- /dev/null +++ b/apps/web/src/app/editor/[project_id]/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher"; + +export default function EditorLayout({ + children, +}: { + children: React.ReactNode; +}) { + useGlobalPrefetcher(); + + return
{children}
; +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a6b86ae4..ca78ee26 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -23,7 +23,7 @@ --muted-foreground: hsl(0 0% 50%); --accent: hsl(216, 13%, 92%); --accent-foreground: hsl(0 0% 2%); - --destructive: hsl(0 100% 40%); + --destructive: hsl(0, 83%, 50%); --destructive-foreground: hsl(0, 0%, 100%); --border: hsl(0 0% 83%); --input: hsl(0 0% 85.1%); diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 4d80b642..70eb69dd 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -4,7 +4,7 @@ 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"; @@ -13,7 +13,7 @@ export function MediaPanel() { const viewMap: Record = { media: , - audio: , + sounds: , text: , stickers: (
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 577417a9..e1212030 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -15,7 +15,7 @@ import { create } from "zustand"; export type Tab = | "media" - | "audio" + | "sounds" | "text" | "stickers" | "effects" @@ -30,9 +30,9 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { icon: VideoIcon, label: "Media", }, - audio: { + sounds: { icon: MusicIcon, - label: "Audio", + label: "Sounds", }, text: { icon: TypeIcon, 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 0d3c3f1e..00000000 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState } from "react"; - -export function AudioView() { - const [search, setSearch] = useState(""); - return ( -
- setSearch(e.target.value)} - /> -
-
- ); -} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx new file mode 100644 index 00000000..306b81d3 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -0,0 +1,463 @@ +"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 } 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + PropertyGroup, + PropertyItem, + PropertyItemValue, +} from "@/components/editor/properties-panel/property-item"; + +export function SoundsView() { + return ( +
+ +
+ + Sound effects + Songs + Saved + +
+ + + + + + + + + + +
+
+ ); +} + +function SoundEffectsView() { + const { + topSoundEffects, + isLoading, + searchQuery, + setSearchQuery, + scrollPosition, + setScrollPosition, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + } = useSoundsStore(); + const { + results: searchResults, + isLoading: isSearching, + loadMore, + hasNextPage, + isLoadingMore, + } = useSoundSearch(searchQuery); + + // Audio playback state + const [playingId, setPlayingId] = useState(null); + const [audioElement, setAudioElement] = useState( + null + ); + + // Scroll position persistence + const scrollAreaRef = useRef(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) => { + 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 ( +
+ setSearchQuery(e.target.value)} + showClearIcon + onClear={() => setSearchQuery("")} + /> + +
+ +
+ {isLoading && !searchQuery && ( +
+ Loading sounds... +
+ )} + {isSearching && searchQuery && ( +
Searching...
+ )} + {displayedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => toggleSavedSound(sound)} + /> + ))} + {!isLoading && !isSearching && displayedSounds.length === 0 && ( +
+ {searchQuery ? "No sounds found" : "No sounds available"} +
+ )} + {isLoadingMore && ( +
+ Loading more sounds... +
+ )} +
+
+
+
+ ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + isSoundSaved, + toggleSavedSound, + clearSavedSounds, + } = useSoundsStore(); + + // Audio playback state + const [playingId, setPlayingId] = useState(null); + const [audioElement, setAudioElement] = useState( + 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 ( +
+
+ Loading saved sounds... +
+
+ ); + } + + if (savedSoundsError) { + return ( +
+
+ Error: {savedSoundsError} +
+
+ ); + } + + if (savedSounds.length === 0) { + return ( +
+ +
+

No saved sounds

+

+ Click the heart icon on any sound to save it here +

+
+
+ ); + } + + return ( +
+
+

+ {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} +

+ + + + + + + Clear all saved sounds? + + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + + + + + + + + +
+ +
+ +
+ {savedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => + toggleSavedSound(convertToSoundEffect(sound)) + } + /> + ))} +
+
+
+
+ ); +} + +function SongsView() { + return
Songs
; +} + +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 ( +
+
+
+ {isPlaying ? ( + + ) : ( + + )} +
+ +
+

{sound.name}

+ + {sound.username} + +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 8f41fbf3..7a2cc7f2 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { ScrollArea } from "../../ui/scroll-area"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "../../ui/button"; import { Scissors, @@ -746,12 +746,7 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - +
{ + 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/ui/input.tsx b/apps/web/src/components/ui/input.tsx index d4cd57a1..4fb0f5b4 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,39 +7,91 @@ import { Button } from "./button"; interface InputProps extends React.ComponentProps<"input"> { showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; + showClearIcon?: boolean; + onClear?: () => void; } const Input = React.forwardRef( ( - { className, type, showPassword, onShowPasswordChange, value, ...props }, + { + className, + type, + 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 ( -
+
{ + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} {...props} /> + {showClear && ( + + )} {showPasswordToggle && ( + + + + Show only commercially licensed + +
+ {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} +
+
+ +
{ + e.preventDefault(); + }} {...props} > - + {children} + - {children} )); + DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 4fb0f5b4..440191f2 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -9,6 +9,7 @@ interface InputProps extends React.ComponentProps<"input"> { onShowPasswordChange?: (show: boolean) => void; showClearIcon?: boolean; onClear?: () => void; + containerClassName?: string; } const Input = React.forwardRef( @@ -16,6 +17,7 @@ const Input = React.forwardRef( { className, type, + containerClassName, showPassword, onShowPasswordChange, showClearIcon, @@ -45,7 +47,7 @@ const Input = React.forwardRef( iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; return ( -
+
void; + // Search state searchQuery: string; searchResults: SoundEffect[]; @@ -72,6 +76,11 @@ export const useSoundsStore = create((set, get) => ({ isLoading: false, error: null, hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, // Search state searchQuery: "", -- cgit v1.3.1