mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user