summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorenkei64 <imenkei64@gmail.com>2025-08-15 10:11:21 +1000
committerGitHub <noreply@github.com>2025-08-15 02:11:21 +0200
commitc3f3345d7b1ac820b411a5adbca3172378642953 (patch)
tree6d80ca38eb908574ab97a2b1e1d30166a72b8b76 /apps/web
parentdefff2fc46978072f2ead822cf8bbd34f7629192 (diff)
downloadopencut-c3f3345d7b1ac820b411a5adbca3172378642953.tar.gz
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>
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/next.config.ts10
-rw-r--r--apps/web/src/app/animation/page.tsx24
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx7
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx1
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx32
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx618
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx2
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx27
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx86
-rw-r--r--apps/web/src/components/ui/input.tsx12
-rw-r--r--apps/web/src/hooks/use-infinite-scroll.ts35
-rw-r--r--apps/web/src/lib/iconify-api.ts242
-rw-r--r--apps/web/src/lib/storage/storage-service.ts21
-rw-r--r--apps/web/src/lib/storage/types.ts2
-rw-r--r--apps/web/src/stores/media-store.ts2
-rw-r--r--apps/web/src/stores/stickers-store.ts178
17 files changed, 1257 insertions, 44 deletions
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 5142905b..cd79efb7 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -32,7 +32,15 @@ const nextConfig: NextConfig = {
},
{
protocol: "https",
- hostname: "res.cloudinary.com",
+ hostname: "api.iconify.design",
+ },
+ {
+ protocol: "https",
+ hostname: "api.simplesvg.com",
+ },
+ {
+ protocol: "https",
+ hostname: "api.unisvg.com",
},
],
},
diff --git a/apps/web/src/app/animation/page.tsx b/apps/web/src/app/animation/page.tsx
new file mode 100644
index 00000000..37be1ef0
--- /dev/null
+++ b/apps/web/src/app/animation/page.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { InputWithBack } from "@/components/ui/input-with-back";
+import { useState } from "react";
+
+export default function AnimationPage() {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ return (
+ <div className="h-dvh flex">
+ <div className="bg-panel p-6 w-[20rem]">
+ <InputWithBack isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
+ </div>
+ <div className="p-6 flex items-end justify-end">
+ <p
+ onClick={() => setIsExpanded(!isExpanded)}
+ className="cursor-pointer hover:opacity-75 transition-opacity"
+ >
+ {isExpanded ? "Collapse" : "Expand"}
+ </p>
+ </div>
+ </div>
+ );
+}
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>
+ );
+}
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<HTMLDivElement>(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({
<div
className={cn(
"h-10 flex items-center gap-3 cursor-default w-full",
- "[&::-webkit-drag-ghost]:opacity-0",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
className
)}
- draggable={true}
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
>
<div className="w-6 h-6 flex-shrink-0 rounded overflow-hidden">
{preview}
@@ -148,7 +152,8 @@ export function DraggableMediaItem({
)}
{/* Custom drag preview */}
- {isDragging &&
+ {isDraggable &&
+ isDragging &&
typeof document !== "undefined" &&
createPortal(
<div
@@ -194,7 +199,7 @@ function PlusButton({
<Button
size="icon"
className={cn(
- "absolute bottom-2 right-2 size-4 bg-background text-foreground",
+ "absolute bottom-2 right-2 size-4 bg-background hover:bg-panel text-foreground",
className
)}
onClick={(e) => {
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<HTMLDivElement | null>(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 (
+ <div ref={setContainerRef} className="relative w-full">
+ <motion.div
+ className="absolute left-0 top-1/2 -translate-y-1/2 cursor-pointer hover:opacity-75 transition-opacity z-10"
+ initial={{
+ x: isExpanded ? 0 : buttonOffset,
+ opacity: isExpanded ? 1 : 0.5,
+ }}
+ animate={{
+ x: isExpanded ? 0 : buttonOffset,
+ opacity: isExpanded ? 1 : 0.5,
+ }}
+ transition={smoothTransition}
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ <Button
+ variant="outline"
+ className="!size-9 rounded-full bg-panel-accent"
+ >
+ <ArrowLeft />
+ </Button>
+ </motion.div>
+ <div
+ className="relative flex-1"
+ style={{ marginLeft: "0px", paddingLeft: "0px" }}
+ >
+ <motion.div
+ className="relative"
+ initial={{
+ marginLeft: isExpanded ? 50 : 0,
+ }}
+ animate={{
+ marginLeft: isExpanded ? 50 : 0,
+ }}
+ transition={smoothTransition}
+ >
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder={placeholder}
+ className="pl-9 bg-panel-accent w-full"
+ value={value}
+ onChange={(e) => onChange?.(e.target.value)}
+ />
+ </motion.div>
+ </div>
+ </div>
+ );
+}
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<HTMLInputElement, InputProps>(
+const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
className,
@@ -29,7 +31,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
},
ref
) => {
- const [isFocused, setIsFocused] = React.useState(false);
+ const [isFocused, setIsFocused] = useState(false);
const isPassword = type === "password";
const showPasswordToggle = isPassword && onShowPasswordChange;
diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts
new file mode 100644
index 00000000..0f8db198
--- /dev/null
+++ b/apps/web/src/hooks/use-infinite-scroll.ts
@@ -0,0 +1,35 @@
+import { useRef, useCallback } from "react";
+
+interface UseInfiniteScrollOptions {
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
+}
+
+export function useInfiniteScroll({
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
+}: UseInfiniteScrollOptions) {
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
+
+ const handleScroll = useCallback(
+ (event: React.UIEvent<HTMLDivElement>) => {
+ if (!enabled) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled]
+ );
+
+ return { scrollAreaRef, handleScroll };
+}
diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts
new file mode 100644
index 00000000..9d1bcb97
--- /dev/null
+++ b/apps/web/src/lib/iconify-api.ts
@@ -0,0 +1,242 @@
+
+export const ICONIFY_HOSTS = [
+ "https://api.iconify.design",
+ "https://api.simplesvg.com",
+ "https://api.unisvg.com",
+];
+
+let currentHost = ICONIFY_HOSTS[0];
+
+async function fetchWithFallback(path: string): Promise<Response> {
+ for (const host of ICONIFY_HOSTS) {
+ try {
+ const response = await fetch(`${host}${path}`, {
+ signal: AbortSignal.timeout(2000),
+ });
+ if (response.ok) {
+ currentHost = host;
+ return response;
+ }
+ } catch (error) {
+ console.warn(`Failed to fetch from ${host}:`, error);
+ }
+ }
+ throw new Error("All API hosts failed");
+}
+
+export interface IconSet {
+ prefix: string;
+ name: string;
+ total: number;
+ author?: {
+ name: string;
+ url?: string;
+ };
+ license?: {
+ title: string;
+ spdx?: string;
+ url?: string;
+ };
+ samples?: string[];
+ category?: string;
+ palette?: boolean;
+}
+
+export interface IconSearchResult {
+ icons: string[];
+ total: number;
+ limit: number;
+ start: number;
+ collections: Record<string, IconSet>;
+}
+
+export interface CollectionInfo {
+ prefix: string;
+ total: number;
+ title?: string;
+ uncategorized?: string[];
+ categories?: Record<string, string[]>;
+ hidden?: string[];
+ aliases?: Record<string, string>;
+}
+
+export async function getCollections(
+ category?: string
+): Promise<Record<string, IconSet>> {
+ try {
+ const response = await fetchWithFallback("/collections?pretty=1");
+ const data = (await response.json()) as Record<string, IconSet>;
+
+ if (category) {
+ const filtered = Object.fromEntries(
+ Object.entries(data).filter(
+ ([_key, info]) => info.category === category
+ )
+ ) as Record<string, IconSet>;
+ return filtered;
+ }
+
+ return data;
+ } catch (error) {
+ console.error("Failed to fetch collections:", error);
+ return {};
+ }
+}
+
+export async function getCollection(
+ prefix: string
+): Promise<CollectionInfo | null> {
+ try {
+ const response = await fetchWithFallback(
+ `/collection?prefix=${prefix}&pretty=1`
+ );
+ return await response.json();
+ } catch (error) {
+ console.error(`Failed to fetch collection ${prefix}:`, error);
+ return null;
+ }
+}
+
+export async function searchIcons(
+ query: string,
+ limit: number = 64,
+ prefixes?: string[],
+ category?: string
+): Promise<IconSearchResult> {
+ const params = new URLSearchParams({
+ query,
+ limit: limit.toString(),
+ pretty: "1",
+ });
+
+ if (prefixes?.length) {
+ params.append("prefixes", prefixes.join(","));
+ }
+
+ if (category) {
+ params.append("category", category);
+ }
+
+ try {
+ const response = await fetchWithFallback(`/search?${params}`);
+ return await response.json();
+ } catch (error) {
+ console.error("Failed to search icons:", error);
+ return {
+ icons: [],
+ total: 0,
+ limit,
+ start: 0,
+ collections: {},
+ };
+ }
+}
+
+export function buildIconSvgUrl(
+ host: string,
+ iconName: string,
+ params?: {
+ color?: string;
+ width?: number;
+ height?: number;
+ flip?: "horizontal" | "vertical" | "horizontal,vertical";
+ rotate?: number | string;
+ }
+): string {
+ const [prefix, name] = iconName.includes(":")
+ ? iconName.split(":")
+ : ["", iconName];
+
+ if (!prefix || !name) {
+ throw new Error('Invalid icon name format. Expected "prefix:name"');
+ }
+
+ const urlParams = new URLSearchParams();
+
+ if (params?.color) {
+ urlParams.append("color", params.color.replace("#", "%23"));
+ }
+
+ if (params?.width) {
+ urlParams.append("width", params.width.toString());
+ }
+
+ if (params?.height) {
+ urlParams.append("height", params.height.toString());
+ }
+
+ if (params?.flip) {
+ urlParams.append("flip", params.flip);
+ }
+
+ if (params?.rotate) {
+ urlParams.append("rotate", params.rotate.toString());
+ }
+
+ const queryString = urlParams.toString();
+ return `${host}/${prefix}/${name}.svg${queryString ? "?" + queryString : ""}`;
+}
+
+export function getIconSvgUrl(
+ iconName: string,
+ params?: Parameters<typeof buildIconSvgUrl>[2]
+): string {
+ return buildIconSvgUrl(currentHost, iconName, params);
+}
+
+export async function downloadSvgAsText(
+ iconName: string,
+ params?: Parameters<typeof getIconSvgUrl>[1]
+): Promise<string> {
+ const url = getIconSvgUrl(iconName, params);
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to download SVG: ${response.statusText}`);
+ }
+ return await response.text();
+}
+
+export function svgToFile(svgText: string, fileName: string): File {
+ const blob = new Blob([svgText], { type: "image/svg+xml" });
+ return new File([blob], fileName, { type: "image/svg+xml" });
+}
+
+export const POPULAR_COLLECTIONS = {
+ general: [
+ { prefix: "mdi", name: "Material Design Icons" },
+ { prefix: "ic", name: "Google Material Icons" },
+ { prefix: "ph", name: "Phosphor" },
+ { prefix: "heroicons", name: "Heroicons" },
+ { prefix: "lucide", name: "Lucide" },
+ { prefix: "tabler", name: "Tabler Icons" },
+ { prefix: "fe", name: "Feather Icons" },
+ { prefix: "bi", name: "Bootstrap Icons" },
+ ],
+ brands: [
+ { prefix: "simple-icons", name: "Simple Icons" },
+ { prefix: "logos", name: "SVG Logos" },
+ { prefix: "skill-icons", name: "Skill Icons" },
+ { prefix: "devicon", name: "Devicon" },
+ { prefix: "fa-brands", name: "Font Awesome Brands" },
+ ],
+ emoji: [
+ { prefix: "noto", name: "Noto Emoji" },
+ { prefix: "twemoji", name: "Twemoji" },
+ { prefix: "fluent-emoji", name: "Fluent Emoji" },
+ { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" },
+ { prefix: "emojione", name: "EmojiOne" },
+ { prefix: "openmoji", name: "OpenMoji" },
+ ],
+};
+
+export function getCategoriesFromCollections(
+ collections: Record<string, IconSet>
+): string[] {
+ const categories = new Set<string>();
+ Object.values(collections).forEach((collection) => {
+ if (collection.category) {
+ categories.add(collection.category);
+ }
+ });
+ return Array.from(categories).sort();
+}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 00d60c47..b3376a5f 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -142,6 +142,7 @@ class StorageService {
width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration,
+ ephemeral: mediaItem.ephemeral,
};
await mediaMetadataAdapter.set(mediaItem.id, metadata);
@@ -161,8 +162,22 @@ class StorageService {
if (!file || !metadata) return null;
- // Create new object URL for the file
- const url = URL.createObjectURL(file);
+ let url: string;
+ if (metadata.type === "image" && (!file.type || file.type === "")) {
+ try {
+ const text = await file.text();
+ if (text.trim().startsWith("<svg")) {
+ const svgBlob = new Blob([text], { type: "image/svg+xml" });
+ url = URL.createObjectURL(svgBlob);
+ } else {
+ url = URL.createObjectURL(file);
+ }
+ } catch {
+ url = URL.createObjectURL(file);
+ }
+ } else {
+ url = URL.createObjectURL(file);
+ }
return {
id: metadata.id,
@@ -173,7 +188,7 @@ class StorageService {
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
- // thumbnailUrl would need to be regenerated or cached separately
+ ephemeral: metadata.ephemeral,
};
}
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
index 127b1690..8f593867 100644
--- a/apps/web/src/lib/storage/types.ts
+++ b/apps/web/src/lib/storage/types.ts
@@ -18,6 +18,8 @@ export interface MediaFileData {
width?: number;
height?: number;
duration?: number;
+ ephemeral?: boolean;
+ sourceStickerIconName?: string;
// File will be stored separately in OPFS
}
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 650c0540..95676d75 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -16,6 +16,8 @@ export interface MediaItem {
width?: number; // For video/image width
height?: number; // For video/image height
fps?: number; // For video frame rate
+ // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted
+ ephemeral?: boolean;
// Text-specific properties
content?: string; // Text content
fontSize?: number; // Font size
diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts
new file mode 100644
index 00000000..da8b926b
--- /dev/null
+++ b/apps/web/src/stores/stickers-store.ts
@@ -0,0 +1,178 @@
+import { create } from "zustand";
+import {
+ getCollections,
+ getCollection,
+ searchIcons,
+ downloadSvgAsText,
+ svgToFile,
+ type IconSet,
+ type CollectionInfo,
+ type IconSearchResult,
+} from "@/lib/iconify-api";
+
+export type StickerCategory = "all" | "general" | "brands" | "emoji";
+
+interface StickersStore {
+ searchQuery: string;
+ selectedCategory: StickerCategory;
+ selectedCollection: string | null;
+ viewMode: "search" | "browse" | "collection";
+
+ collections: Record<string, IconSet>;
+ currentCollection: CollectionInfo | null;
+ searchResults: IconSearchResult | null;
+ recentStickers: string[];
+ isLoadingCollections: boolean;
+ isLoadingCollection: boolean;
+ isSearching: boolean;
+ isDownloading: boolean;
+
+ setSearchQuery: (query: string) => void;
+ setSelectedCategory: (category: StickerCategory) => void;
+ setSelectedCollection: (collection: string | null) => void;
+ setViewMode: (mode: "search" | "browse" | "collection") => void;
+
+ loadCollections: () => Promise<void>;
+ loadCollection: (prefix: string) => Promise<void>;
+ searchStickers: (query: string) => Promise<void>;
+ downloadSticker: (iconName: string) => Promise<File | null>;
+
+ addToRecentStickers: (iconName: string) => void;
+ clearRecentStickers: () => void;
+}
+
+const MAX_RECENT_STICKERS = 50;
+
+export const useStickersStore = create<StickersStore>((set, get) => ({
+ searchQuery: "",
+ selectedCategory: "all",
+ selectedCollection: null,
+ viewMode: "browse",
+
+ collections: {},
+ currentCollection: null,
+ searchResults: null,
+ recentStickers: [],
+
+ isLoadingCollections: false,
+ isLoadingCollection: false,
+ isSearching: false,
+ isDownloading: false,
+
+ setSearchQuery: (query) => set({ searchQuery: query }),
+
+ setSelectedCategory: (category) =>
+ set({
+ selectedCategory: category,
+ viewMode: "browse",
+ selectedCollection: null,
+ currentCollection: null,
+ }),
+
+ setSelectedCollection: (collection) => {
+ set({
+ selectedCollection: collection,
+ viewMode: collection ? "collection" : "browse",
+ currentCollection: null,
+ });
+
+ if (collection) {
+ get().loadCollection(collection);
+ }
+ },
+
+ setViewMode: (mode) => set({ viewMode: mode }),
+
+ loadCollections: async () => {
+ set({ isLoadingCollections: true });
+ try {
+ const collections = await getCollections();
+ set({ collections });
+ } catch (error) {
+ console.error("Failed to load collections:", error);
+ } finally {
+ set({ isLoadingCollections: false });
+ }
+ },
+
+ loadCollection: async (prefix: string) => {
+ set({ isLoadingCollection: true });
+ try {
+ const collection = await getCollection(prefix);
+ set({ currentCollection: collection });
+ } catch (error) {
+ console.error(`Failed to load collection ${prefix}:`, error);
+ set({ currentCollection: null });
+ } finally {
+ set({ isLoadingCollection: false });
+ }
+ },
+
+ searchStickers: async (query: string) => {
+ if (!query.trim()) {
+ set({ searchResults: null, viewMode: "browse" });
+ return;
+ }
+
+ const { selectedCategory } = get();
+
+ set({ isSearching: true, viewMode: "search" });
+ try {
+ let category: string | undefined;
+
+ if (selectedCategory !== "all") {
+ if (selectedCategory === "general") {
+ category = "General";
+ } else if (selectedCategory === "brands") {
+ category = "Brands / Social";
+ } else if (selectedCategory === "emoji") {
+ category = "Emoji";
+ }
+ }
+
+ const results = await searchIcons(query, 100, undefined, category);
+ set({ searchResults: results });
+ } catch (error) {
+ console.error("Search failed:", error);
+ set({ searchResults: null });
+ } finally {
+ set({ isSearching: false });
+ }
+ },
+
+ downloadSticker: async (iconName: string) => {
+ set({ isDownloading: true });
+ try {
+ const svgText = await downloadSvgAsText(iconName, {
+ width: 200,
+ height: 200,
+ });
+
+ const fileName = `${iconName.replace(":", "-")}.svg`;
+ const file = svgToFile(svgText, fileName);
+
+ get().addToRecentStickers(iconName);
+
+ return file;
+ } catch (error) {
+ console.error(`Failed to download sticker ${iconName}:`, error);
+ return null;
+ } finally {
+ set({ isDownloading: false });
+ }
+ },
+
+ addToRecentStickers: (iconName: string) => {
+ set((state) => {
+ const recent = [
+ iconName,
+ ...state.recentStickers.filter((s) => s !== iconName),
+ ];
+ return {
+ recentStickers: recent.slice(0, MAX_RECENT_STICKERS),
+ };
+ });
+ },
+
+ clearRecentStickers: () => set({ recentStickers: [] }),
+}));