diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/panels/assets/views/stickers.tsx | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views/stickers.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/stickers.tsx | 615 |
1 files changed, 615 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx new file mode 100644 index 00000000..6b48e775 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -0,0 +1,615 @@ +"use client"; + +import Image from "next/image"; +import type { CSSProperties } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { Button } from "@/components/ui/button"; +import { InputWithBack } from "@/components/ui/input-with-back"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { + buildIconSvgUrl, + getIconSvgUrl, + ICONIFY_HOSTS, + POPULAR_COLLECTIONS, +} from "@/lib/iconify-api"; +import { useStickersStore } from "@/stores/stickers-store"; +import type { StickerCategory } from "@/types/stickers"; +import { cn } from "@/utils/ui"; +import { + ArrowRightIcon, + HappyIcon, + ClockIcon, + LayoutGridIcon, + MultiplicationSignIcon, + SparklesIcon, + HashtagIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Spinner } from "@/components/ui/spinner"; + +function isStickerCategory(value: string): value is StickerCategory { + return STICKER_CATEGORIES.includes(value as StickerCategory); +} + +export function StickersView() { + const { selectedCategory, setSelectedCategory } = useStickersStore(); + + return ( + <BaseView + value={selectedCategory} + onValueChange={(v) => { + if (isStickerCategory(v)) { + setSelectedCategory({ category: v }); + } + }} + tabs={[ + { + value: "all", + label: "All", + icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, + content: <StickersContentView category="all" />, + }, + { + value: "general", + label: "Icons", + icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, + content: <StickersContentView category="general" />, + }, + { + value: "brands", + label: "Brands", + icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, + content: <StickersContentView category="brands" />, + }, + { + value: "emoji", + label: "Emoji", + icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, + content: <StickersContentView category="emoji" />, + }, + ]} + className="flex h-full flex-col overflow-hidden p-0" + /> + ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, + capSize = false, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; + capSize?: boolean; +}) { + const gridStyle: CSSProperties & { + "--sticker-min": string; + "--sticker-max"?: string; + } = { + gridTemplateColumns: capSize + ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" + : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", + "--sticker-min": "96px", + ...(capSize ? { "--sticker-max": "160px" } : {}), + }; + + return ( + <div className="grid gap-2" style={gridStyle}> + {icons.map((iconName) => ( + <StickerItem + key={iconName} + iconName={iconName} + onAdd={onAdd} + isAdding={addingSticker === iconName} + capSize={capSize} + /> + ))} + </div> + ); +} + +function CollectionGrid({ + collections, + onSelectCollection, +}: { + collections: Array<{ + prefix: string; + name: string; + total: number; + category?: string; + }>; + onSelectCollection: ({ prefix }: { prefix: string }) => void; +}) { + return ( + <div className="grid grid-cols-1 gap-2"> + {collections.map((collection) => ( + <CollectionItem + key={collection.prefix} + title={collection.name} + subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`} + onClick={() => onSelectCollection({ prefix: collection.prefix })} + /> + ))} + </div> + ); +} + +function EmptyView({ message }: { message: string }) { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={HappyIcon} + className="text-muted-foreground size-10" + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">No stickers found</p> + <p className="text-muted-foreground text-sm text-balance">{message}</p> + </div> + </div> + ); +} + +function StickersContentView({ category }: { category: StickerCategory }) { + const { + searchQuery, + selectedCollection, + viewMode, + collections, + currentCollection, + searchResults, + recentStickers, + isLoadingCollections, + isLoadingCollection, + isSearching, + setSearchQuery, + setSelectedCollection, + loadCollections, + searchStickers, + addStickerToTimeline, + clearRecentStickers, + setSelectedCategory, + addingSticker, + } = useStickersStore(); + + 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(); + } + }, [collections, loadCollections]); + + useEffect(() => { + const timer = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery({ query: localSearchQuery }); + if (localSearchQuery.trim()) { + searchStickers({ query: localSearchQuery }); + } + } + }, 500); + + return () => clearTimeout(timer); + }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); + + const handleAddSticker = async (iconName: string) => { + try { + await addStickerToTimeline({ iconName }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } + }; + + 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="mt-1 flex h-full flex-col gap-5 p-4"> + <div className="space-y-3"> + <InputWithBack + isExpanded={isInCollection} + setIsExpanded={(expanded) => { + if (!expanded && isInCollection) { + setSelectedCollection({ collection: null }); + } + }} + placeholder={ + category === "all" + ? "Search all stickers" + : category === "general" + ? "Search icons" + : category === "brands" + ? "Search brands" + : "Search Emojis" + } + value={localSearchQuery} + onChange={setLocalSearchQuery} + disableAnimation={true} + /> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea + className="h-full flex-1" + ref={scrollAreaRef} + onScrollCapture={handleScroll} + > + <div className="flex h-full flex-col gap-4"> + {recentStickers.length > 0 && viewMode === "browse" && ( + <div className="h-full"> + <div className="mb-2 flex items-center gap-2"> + <HugeiconsIcon + icon={ClockIcon} + className="text-muted-foreground size-4" + /> + <span className="text-sm font-medium">Recent</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={clearRecentStickers} + className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" + > + <HugeiconsIcon + icon={MultiplicationSignIcon} + className="text-muted-foreground size-3" + /> + </button> + </TooltipTrigger> + <TooltipContent> + <p>Clear recent stickers</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <StickerGrid + icons={recentStickers.slice(0, 12)} + onAdd={handleAddSticker} + addingSticker={addingSticker} + capSize + /> + </div> + )} + + {viewMode === "collection" && selectedCollection && ( + <div className="h-full"> + {isLoadingCollection ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : showCollectionItems ? ( + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + /> + ) : ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + )} + </div> + )} + + {viewMode === "search" && ( + <div className="h-full"> + {isSearching ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : searchResults?.icons.length ? ( + <> + <div className="mb-3 flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> + </div> + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + capSize + /> + </> + ) : searchQuery ? ( + <div className="flex flex-col items-center justify-center gap-3 py-8"> + <EmptyView + message={`No stickers found for "${searchQuery}"`} + /> + {category !== "all" && ( + <Button + variant="outline" + onClick={() => { + const q = localSearchQuery || searchQuery; + if (q) { + setSearchQuery({ query: q }); + } + setSelectedCategory({ category: "all" }); + if (q) { + searchStickers({ query: q }); + } + }} + > + Search in all icons + </Button> + )} + </div> + ) : null} + </div> + )} + + {viewMode === "browse" && !selectedCollection && ( + <div className="h-full space-y-4"> + {isLoadingCollections ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : ( + <> + {category !== "all" && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections} + onSelectCollection={({ prefix }) => + setSelectedCollection({ collection: prefix }) + } + /> + </div> + )} + + {category === "all" && filteredCollections.length > 0 && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections.slice( + 0, + collectionsToShow, + )} + onSelectCollection={({ prefix }) => + setSelectedCollection({ collection: prefix }) + } + /> + </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="h-auto justify-between rounded-md py-2" + onClick={onClick} + > + <div className="text-left"> + <p className="font-medium">{title}</p> + <p className="text-muted-foreground text-xs">{subtitle}</p> + </div> + <HugeiconsIcon icon={ArrowRightIcon} className="size-4" /> + </Button> + ); +} + +interface StickerItemProps { + iconName: string; + onAdd: (iconName: string) => void; + isAdding?: boolean; + capSize?: boolean; +} + +function StickerItem({ + iconName, + onAdd, + isAdding, + capSize = false, +}: StickerItemProps) { + const [imageError, setImageError] = useState(false); + const [hostIndex, setHostIndex] = useState(0); + + useEffect(() => { + if (!iconName) { + return; + } + setImageError(false); + setHostIndex(0); + }, [iconName]); + + const displayName = iconName.split(":")[1] || iconName; + const collectionPrefix = iconName.split(":")[0]; + + const preview = imageError ? ( + <div className="flex size-full items-center justify-center p-2"> + <span className="text-muted-foreground text-center text-xs break-all"> + {displayName} + </span> + </div> + ) : ( + <div className="flex size-full items-center justify-center p-4"> + <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="size-full object-contain" + style={ + capSize + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } + : undefined + } + 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 && "pointer-events-none opacity-50", + )} + > + <DraggableItem + name={displayName} + preview={preview} + dragData={{ + id: iconName, + type: "sticker", + name: displayName, + iconName, + }} + onAddToTimeline={() => onAdd(iconName)} + aspectRatio={1} + shouldShowLabel={false} + isRounded={true} + variant="card" + className="" + containerClassName="w-full" + /> + {isAdding && ( + <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> + <Spinner className="size-6 text-white" /> + </div> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-medium">{displayName}</p> + <p className="text-muted-foreground text-xs">{collectionPrefix}</p> + </div> + </TooltipContent> + </Tooltip> + ); +} |
