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 --- .../src/components/editor/media-panel/index.tsx | 7 +- .../components/editor/media-panel/views/media.tsx | 1 + .../components/editor/media-panel/views/sounds.tsx | 32 +- .../editor/media-panel/views/stickers.tsx | 618 +++++++++++++++++++++ apps/web/src/components/editor/timeline/index.tsx | 2 + apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/draggable-item.tsx | 27 +- apps/web/src/components/ui/input-with-back.tsx | 86 +++ apps/web/src/components/ui/input.tsx | 12 +- 9 files changed, 747 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/editor/media-panel/views/stickers.tsx create mode 100644 apps/web/src/components/ui/input-with-back.tsx (limited to 'apps/web/src/components') 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: , sounds: , text: , - stickers: ( -
- Stickers view coming soon... -
- ), + stickers: , effects: (
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(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}

+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index c48075d1..34d38234 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -41,8 +41,10 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useProjectStore } from "@/stores/project-store"; + import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { processMediaFiles } from "@/lib/media-processing"; + import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 5558eee0..9096e415 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", outline: - "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index ff6766fd..92aa677b 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -25,6 +25,7 @@ export interface DraggableMediaItemProps { showLabel?: boolean; rounded?: boolean; variant?: "card" | "compact"; + isDraggable?: boolean; } export function DraggableMediaItem({ @@ -39,11 +40,14 @@ export function DraggableMediaItem({ showLabel = true, rounded = true, variant = "card", + isDraggable = true, }: DraggableMediaItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const dragRef = useRef(null); - const currentTime = usePlaybackStore((state) => state.currentTime); + const currentTime = isDraggable + ? usePlaybackStore((state) => state.currentTime) + : 0; const handleAddToTimeline = () => { onAddToTimeline?.(currentTime); @@ -100,11 +104,11 @@ export function DraggableMediaItem({ className={cn( "bg-panel-accent relative overflow-hidden", rounded && "rounded-md", - "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding + isDraggable && "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding )} - draggable={true} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} > {preview} {!isDragging && ( @@ -132,12 +136,12 @@ export function DraggableMediaItem({
{preview} @@ -148,7 +152,8 @@ export function DraggableMediaItem({ )} {/* Custom drag preview */} - {isDragging && + {isDraggable && + isDragging && typeof document !== "undefined" && createPortal(
{ diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx new file mode 100644 index 00000000..ea225c14 --- /dev/null +++ b/apps/web/src/components/ui/input-with-back.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Search } from "lucide-react"; +import { motion } from "motion/react"; +import { useState, useEffect } from "react"; + +interface InputWithBackProps { + isExpanded: boolean; + setIsExpanded: (isExpanded: boolean) => void; + placeholder?: string; + value?: string; + onChange?: (value: string) => void; +} + +export function InputWithBack({ + isExpanded, + setIsExpanded, + placeholder = "Search anything", + value, + onChange, +}: InputWithBackProps) { + const [containerRef, setContainerRef] = useState(null); + const [buttonOffset, setButtonOffset] = useState(-60); + + const smoothTransition = { + duration: 0.35, + ease: [0.25, 0.1, 0.25, 1] as const, + }; + + useEffect(() => { + if (containerRef) { + const rect = containerRef.getBoundingClientRect(); + setButtonOffset(-rect.left - 48); + } + }, [containerRef]); + + return ( +
+ setIsExpanded(!isExpanded)} + > + + +
+ + + onChange?.(e.target.value)} + /> + +
+
+ ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 65c7660d..8f195a50 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,10 +1,12 @@ -import * as React from "react"; -import { Eye, EyeOff, X } from "lucide-react"; +"use client"; +import { Eye, EyeOff, X } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "./button"; +import { forwardRef, ComponentProps } from "react"; +import { useState } from "react"; -interface InputProps extends React.ComponentProps<"input"> { +interface InputProps extends ComponentProps<"input"> { showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; showClearIcon?: boolean; @@ -12,7 +14,7 @@ interface InputProps extends React.ComponentProps<"input"> { containerClassName?: string; } -const Input = React.forwardRef( +const Input = forwardRef( ( { className, @@ -29,7 +31,7 @@ const Input = React.forwardRef( }, ref ) => { - const [isFocused, setIsFocused] = React.useState(false); + const [isFocused, setIsFocused] = useState(false); const isPassword = type === "password"; const showPasswordToggle = isPassword && onShowPasswordChange; -- cgit v1.3.1