diff options
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
4 files changed, 635 insertions, 23 deletions
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 21df9924..89b53b64 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 { SoundsView } from "./views/sounds"; +import { StickersView } from "./views/stickers"; import { Separator } from "@/components/ui/separator"; import { SettingsView } from "./views/settings"; import { Captions } from "./views/captions"; @@ -16,11 +17,7 @@ export function MediaPanel() { media: <MediaView />, sounds: <SoundsView />, text: <TextView />, - stickers: ( - <div className="p-4 text-muted-foreground"> - Stickers view coming soon... - </div> - ), + stickers: <StickersView />, effects: ( <div className="p-4 text-muted-foreground"> Effects view coming soon... 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 772ef4ab..0f0ed92d 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -146,6 +146,7 @@ export function MediaView() { useEffect(() => { let filtered = mediaItems.filter((item) => { + if (item.ephemeral) return false; if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { return false; } diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index ed0db8ab..303eb858 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -1,7 +1,7 @@ "use client"; import { Input } from "@/components/ui/input"; -import { useState, useMemo, useRef, useEffect } from "react"; +import { useState, useMemo, 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"; @@ -32,6 +32,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; export function SoundsView() { return ( @@ -96,35 +97,30 @@ function SoundEffectsView() { null ); - // Scroll position persistence - const scrollAreaRef = useRef<HTMLDivElement>(null); + const { scrollAreaRef, handleScroll } = useInfiniteScroll({ + onLoadMore: loadMore, + hasMore: hasNextPage, + isLoading: isLoadingMore || isSearching, + }); - // 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 + }, 100); 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; + const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => { + const { scrollTop } = 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(); - } + handleScroll(event); }; - - // Use your existing design, just swap the data source + const displayedSounds = useMemo(() => { const sounds = searchQuery ? searchResults : topSoundEffects; return sounds; @@ -199,7 +195,7 @@ function SoundEffectsView() { <ScrollArea className="flex-1 h-full" ref={scrollAreaRef} - onScrollCapture={handleScroll} + onScrollCapture={handleScrollWithPosition} > <div className="flex flex-col gap-4"> {isLoading && !searchQuery && ( diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx new file mode 100644 index 00000000..937a968e --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -0,0 +1,618 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useStickersStore } from "@/stores/stickers-store"; +import { useMediaStore } from "@/stores/media-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { + Loader2, + Grid3X3, + Hash, + Smile, + Clock, + X, + Sparkles, + ArrowRight, + StickerIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { + getIconSvgUrl, + buildIconSvgUrl, + ICONIFY_HOSTS, + POPULAR_COLLECTIONS, +} from "@/lib/iconify-api"; +import { cn, generateUUID } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import Image from "next/image"; +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { InputWithBack } from "@/components/ui/input-with-back"; +import { StickerCategory } from "@/stores/stickers-store"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; + +export function StickersView() { + const { selectedCategory, setSelectedCategory } = useStickersStore(); + + return ( + <div className="h-full flex flex-col"> + <Tabs + value={selectedCategory} + onValueChange={(v) => { + if (["all", "general", "brands", "emoji"].includes(v)) { + setSelectedCategory(v as StickerCategory); + } + }} + className="flex flex-col h-full" + > + <div className="px-3 pt-4 pb-0"> + <TabsList> + <TabsTrigger value="all" className="gap-1"> + <Grid3X3 className="h-3 w-3" /> + All + </TabsTrigger> + <TabsTrigger value="general" className="gap-1"> + <Sparkles className="h-3 w-3" /> + Icons + </TabsTrigger> + <TabsTrigger value="brands" className="gap-1"> + <Hash className="h-3 w-3" /> + Brands + </TabsTrigger> + <TabsTrigger value="emoji" className="gap-1"> + <Smile className="h-3 w-3" /> + Emoji + </TabsTrigger> + </TabsList> + </div> + <Separator className="my-4" /> + <TabsContent + value="all" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <StickersContentView category="all" /> + </TabsContent> + <TabsContent + value="general" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <StickersContentView category="general" /> + </TabsContent> + <TabsContent + value="brands" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <StickersContentView category="brands" /> + </TabsContent> + <TabsContent + value="emoji" + className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" + > + <StickersContentView category="emoji" /> + </TabsContent> + </Tabs> + </div> + ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; +}) { + return ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 112px)", + }} + > + {icons.map((iconName) => ( + <StickerItem + key={iconName} + iconName={iconName} + onAdd={onAdd} + isAdding={addingSticker === iconName} + /> + ))} + </div> + ); +} + +function CollectionGrid({ + collections, + onSelectCollection, +}: { + collections: Array<{ + prefix: string; + name: string; + total: number; + category?: string; + }>; + onSelectCollection: (prefix: string) => void; +}) { + return ( + <div className="grid grid-cols-1 gap-2 h-full overflow-hidden"> + {collections.map((collection) => ( + <CollectionItem + key={collection.prefix} + title={collection.name} + subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`} + onClick={() => onSelectCollection(collection.prefix)} + /> + ))} + </div> + ); +} + +function EmptyView({ message }: { message: string }) { + return ( + <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3"> + <StickerIcon + 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 stickers found</p> + <p className="text-sm text-muted-foreground text-balance">{message}</p> + </div> + </div> + ); +} + +function StickersContentView({ category }: { category: StickerCategory }) { + const { activeProject } = useProjectStore(); + const { addMediaAtTime } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + const { addMediaItem } = useMediaStore(); + const { + searchQuery, + selectedCollection, + viewMode, + collections, + currentCollection, + searchResults, + recentStickers, + isLoadingCollections, + isLoadingCollection, + isSearching, + setSearchQuery, + setSelectedCollection, + loadCollections, + searchStickers, + downloadSticker, + clearRecentStickers, + } = useStickersStore(); + + const [addingSticker, setAddingSticker] = useState<string | null>(null); + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + const [collectionsToShow, setCollectionsToShow] = useState(20); + const [showCollectionItems, setShowCollectionItems] = useState(false); + + const filteredCollections = useMemo(() => { + if (category === "all") { + return Object.entries(collections).map(([prefix, collection]) => ({ + prefix, + name: collection.name, + total: collection.total, + category: collection.category, + })); + } + + const collectionList = + POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; + if (!collectionList) return []; + + return collectionList + .map((c) => { + const collection = collections[c.prefix]; + return collection + ? { + prefix: c.prefix, + name: c.name, + total: collection.total, + } + : null; + }) + .filter(Boolean) as Array<{ + prefix: string; + name: string; + total: number; + }>; + }, [collections, category]); + + const { scrollAreaRef, handleScroll } = useInfiniteScroll({ + onLoadMore: () => setCollectionsToShow((prev) => prev + 20), + hasMore: filteredCollections.length > collectionsToShow, + isLoading: isLoadingCollections, + enabled: viewMode === "browse" && !selectedCollection && category === "all", + }); + + useEffect(() => { + if (Object.keys(collections).length === 0) { + loadCollections(); + } + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery(localSearchQuery); + if (localSearchQuery.trim()) { + searchStickers(localSearchQuery); + } + } + }, 500); + + return () => clearTimeout(timer); + }, [localSearchQuery]); + + const handleAddSticker = async (iconName: string) => { + if (!activeProject) { + toast.error("No active project"); + return; + } + + setAddingSticker(iconName); + + try { + const file = await downloadSticker(iconName); + + if (!file) { + throw new Error("Failed to download sticker"); + } + + const mediaItem = { + name: iconName.replace(":", "-"), + type: "image" as const, + file, + url: URL.createObjectURL(file), + width: 200, + height: 200, + duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + ephemeral: false, + }; + + await addMediaItem(activeProject.id, mediaItem); + + const added = useMediaStore + .getState() + .mediaItems.find( + (m) => m.url === mediaItem.url && m.name === mediaItem.name + ); + if (!added) throw new Error("Sticker not in media store"); + + addMediaAtTime(added, currentTime); + + toast.success(`Added "${iconName}" to timeline`); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } finally { + setAddingSticker(null); + } + }; + + const iconsToDisplay = useMemo(() => { + if (viewMode === "search" && searchResults) { + return searchResults.icons; + } + + if (viewMode === "collection" && currentCollection) { + const icons: string[] = []; + + if (currentCollection.uncategorized) { + icons.push( + ...currentCollection.uncategorized.map( + (name) => `${currentCollection.prefix}:${name}` + ) + ); + } + + if (currentCollection.categories) { + Object.values(currentCollection.categories).forEach((categoryIcons) => { + icons.push( + ...categoryIcons.map( + (name) => `${currentCollection.prefix}:${name}` + ) + ); + }); + } + + return icons.slice(0, 100); + } + + return []; + }, [viewMode, searchResults, currentCollection]); + + const isInCollection = viewMode === "collection" && !!selectedCollection; + + useEffect(() => { + if (isInCollection) { + setShowCollectionItems(false); + const timer = setTimeout(() => setShowCollectionItems(true), 350); + return () => clearTimeout(timer); + } else { + setShowCollectionItems(false); + } + }, [isInCollection]); + + return ( + <div className="flex flex-col gap-5 mt-1 h-full"> + <div className="space-y-3"> + <InputWithBack + isExpanded={isInCollection} + setIsExpanded={(expanded) => { + if (!expanded && isInCollection) { + setSelectedCollection(null); + } + }} + placeholder="Search icons..." + value={localSearchQuery} + onChange={setLocalSearchQuery} + /> + </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 h-full"> + {recentStickers.length > 0 && viewMode === "browse" && ( + <div className="h-full"> + <div className="flex items-center gap-2 mb-2"> + <Clock className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">Recent</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + onClick={clearRecentStickers} + className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center" + > + <X className="h-3 w-3 text-muted-foreground" /> + </button> + </TooltipTrigger> + <TooltipContent> + <p>Clear recent stickers</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <StickerGrid + icons={recentStickers.slice(0, 12)} + onAdd={handleAddSticker} + addingSticker={addingSticker} + /> + </div> + )} + + {viewMode === "collection" && selectedCollection && ( + <div className="h-full"> + {isLoadingCollection ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : showCollectionItems ? ( + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + /> + ) : ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + )} + </div> + )} + + {viewMode === "search" && ( + <div className="h-full"> + {isSearching ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : searchResults?.icons.length ? ( + <> + <div className="flex items-center justify-between mb-3"> + <span className="text-sm text-muted-foreground"> + {searchResults.total} results + </span> + </div> + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + /> + </> + ) : searchQuery ? ( + <EmptyView + message={`No stickers found for "${searchQuery}"`} + /> + ) : null} + </div> + )} + + {viewMode === "browse" && !selectedCollection && ( + <div className="space-y-4 h-full"> + {isLoadingCollections ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : ( + <> + {category !== "all" && ( + <div className="h-full"> + <h3 className="text-sm font-medium mb-2"> + Popular{" "} + {category === "general" + ? "Icon Sets" + : category === "brands" + ? "Brand Icons" + : "Emoji Sets"} + </h3> + <CollectionGrid + collections={filteredCollections} + onSelectCollection={setSelectedCollection} + /> + </div> + )} + + {category === "all" && filteredCollections.length > 0 && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections.slice( + 0, + collectionsToShow + )} + onSelectCollection={setSelectedCollection} + /> + </div> + )} + </> + )} + </div> + )} + </div> + </ScrollArea> + </div> + </div> + ); +} + +interface CollectionItemProps { + title: string; + subtitle: string; + onClick: () => void; +} + +function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { + return ( + <Button + variant="outline" + className="justify-between h-auto py-2 " + onClick={onClick} + > + <div className="text-left"> + <p className="font-medium">{title}</p> + <p className="text-xs text-muted-foreground">{subtitle}</p> + </div> + <ArrowRight className="h-4 w-4" /> + </Button> + ); +} + +interface StickerItemProps { + iconName: string; + onAdd: (iconName: string) => void; + isAdding?: boolean; +} + +function StickerItem({ iconName, onAdd, isAdding }: StickerItemProps) { + const [imageError, setImageError] = useState(false); + const [hostIndex, setHostIndex] = useState(0); + + useEffect(() => { + setImageError(false); + setHostIndex(0); + }, [iconName]); + + const displayName = iconName.split(":")[1] || iconName; + const collectionPrefix = iconName.split(":")[0]; + + const preview = imageError ? ( + <div className="w-full h-full flex items-center justify-center p-2"> + <span className="text-xs text-muted-foreground text-center break-all"> + {displayName} + </span> + </div> + ) : ( + <div className="w-full h-full p-4 flex items-center justify-center"> + <Image + src={ + hostIndex === 0 + ? getIconSvgUrl(iconName, { width: 64, height: 64 }) + : buildIconSvgUrl( + ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], + iconName, + { width: 64, height: 64 } + ) + } + alt={displayName} + width={64} + height={64} + className="w-full h-full object-contain" + onError={() => { + const next = hostIndex + 1; + if (next < ICONIFY_HOSTS.length) { + setHostIndex(next); + } else { + setImageError(true); + } + }} + loading="lazy" + unoptimized + /> + </div> + ); + + return ( + <Tooltip> + <TooltipTrigger asChild> + <div + className={cn( + "relative", + isAdding && "opacity-50 pointer-events-none" + )} + > + <DraggableMediaItem + name={displayName} + preview={preview} + dragData={{ + type: "sticker", + iconName: iconName, + name: displayName, + }} + onAddToTimeline={() => onAdd(iconName)} + aspectRatio={1} + showLabel={false} + rounded={true} + variant="card" + className="" + isDraggable={false} + /> + {isAdding && ( + <div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10"> + <Loader2 className="h-6 w-6 animate-spin text-white" /> + </div> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-medium">{displayName}</p> + <p className="text-xs text-muted-foreground">{collectionPrefix}</p> + </div> + </TooltipContent> + </Tooltip> + ); +} |
