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 <mazewinther@gmail.com>
This commit is contained in:
enkei64
2025-08-15 02:11:21 +02:00
committed by GitHub
co-authored by Maze Winther
parent defff2fc46
commit c3f3345d7b
17 changed files with 1257 additions and 44 deletions
@@ -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...
@@ -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;
}
@@ -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 && (
@@ -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>
);
}
@@ -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";