From c3f3345d7b1ac820b411a5adbca3172378642953 Mon Sep 17 00:00:00 2001 From: enkei64 Date: Fri, 15 Aug 2025 10:11:21 +1000 Subject: feat: stickers panel (#539) * Stickers panel base complete * Improve dark mode stickers background for visibility * Prevent stickers from being too small or too big * Improve UI, added credit for Iconify API * Allow manually loading more collections * Add a maximum width of 200px so stickers aren't too big * cleanup * style: hover state of button * refactor: input component * fix: mark input component as client * so much stuff --------- Co-authored-by: Maze Winther --- .../components/editor/media-panel/views/media.tsx | 1 + .../components/editor/media-panel/views/sounds.tsx | 32 +- .../editor/media-panel/views/stickers.tsx | 618 +++++++++++++++++++++ 3 files changed, 633 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/editor/media-panel/views/stickers.tsx (limited to 'apps/web/src/components/editor/media-panel/views') 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(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) => { - const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; + const handleScrollWithPosition = (event: React.UIEvent) => { + 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() {
{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 ( +
+ { + if (["all", "general", "brands", "emoji"].includes(v)) { + setSelectedCategory(v as StickerCategory); + } + }} + className="flex flex-col h-full" + > +
+ + + + All + + + + Icons + + + + Brands + + + + Emoji + + +
+ + + + + + + + + + + + + +
+
+ ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; +}) { + return ( +
+ {icons.map((iconName) => ( + + ))} +
+ ); +} + +function CollectionGrid({ + collections, + onSelectCollection, +}: { + collections: Array<{ + prefix: string; + name: string; + total: number; + category?: string; + }>; + onSelectCollection: (prefix: string) => void; +}) { + return ( +
+ {collections.map((collection) => ( + onSelectCollection(collection.prefix)} + /> + ))} +
+ ); +} + +function EmptyView({ message }: { message: string }) { + return ( +
+ +
+

No stickers found

+

{message}

+
+
+ ); +} + +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(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 ( +
+
+ { + if (!expanded && isInCollection) { + setSelectedCollection(null); + } + }} + placeholder="Search icons..." + value={localSearchQuery} + onChange={setLocalSearchQuery} + /> +
+ +
+ +
+ {recentStickers.length > 0 && viewMode === "browse" && ( +
+
+ + Recent + + + + + + +

Clear recent stickers

+
+
+
+
+ +
+ )} + + {viewMode === "collection" && selectedCollection && ( +
+ {isLoadingCollection ? ( +
+ +
+ ) : showCollectionItems ? ( + + ) : ( +
+ +
+ )} +
+ )} + + {viewMode === "search" && ( +
+ {isSearching ? ( +
+ +
+ ) : searchResults?.icons.length ? ( + <> +
+ + {searchResults.total} results + +
+ + + ) : searchQuery ? ( + + ) : null} +
+ )} + + {viewMode === "browse" && !selectedCollection && ( +
+ {isLoadingCollections ? ( +
+ +
+ ) : ( + <> + {category !== "all" && ( +
+

+ Popular{" "} + {category === "general" + ? "Icon Sets" + : category === "brands" + ? "Brand Icons" + : "Emoji Sets"} +

+ +
+ )} + + {category === "all" && filteredCollections.length > 0 && ( +
+ +
+ )} + + )} +
+ )} +
+
+
+
+ ); +} + +interface CollectionItemProps { + title: string; + subtitle: string; + onClick: () => void; +} + +function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { + return ( + + ); +} + +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 ? ( +
+ + {displayName} + +
+ ) : ( +
+ {displayName} { + const next = hostIndex + 1; + if (next < ICONIFY_HOSTS.length) { + setHostIndex(next); + } else { + setImageError(true); + } + }} + loading="lazy" + unoptimized + /> +
+ ); + + return ( + + +
+ onAdd(iconName)} + aspectRatio={1} + showLabel={false} + rounded={true} + variant="card" + className="" + isDraggable={false} + /> + {isAdding && ( +
+ +
+ )} +
+
+ +
+

{displayName}

+

{collectionPrefix}

+
+
+
+ ); +} -- cgit v1.3.1